From d3c8fce967c17252c8e1171de0ddecf137c5200e Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 28 Aug 2021 21:47:43 +0700 Subject: [PATCH] Implement batch file list #89 --- README.md | 1 + public/menu.js | 6 + src/App.jsx | 311 +++++++++++++++++++++++++++---------------- src/HelpSheet.jsx | 4 + src/NoFileLoaded.jsx | 4 +- src/SegmentList.jsx | 4 +- src/dialogs.jsx | 35 ++++- 7 files changed, 239 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 3f7a8623..bf46eb8a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic - Lossless merge/concatenation of arbitrary files (with identical codecs parameters, e.g. from the same camera) - Lossless stream editing: Combine arbitrary tracks from multiple files (ex. add music or subtitle track to a video file) - Losslessly extract all tracks from a file (extract video, audio, subtitle, attachments and other tracks from one file into separate files) +- Batch view for fast multi-file workflow - Remux into any compatible output format - Take full-resolution snapshots from videos in JPEG/PNG format - Manual input of cutpoint times diff --git a/public/menu.js b/public/menu.js index d3897f2a..b99ccb6a 100644 --- a/public/menu.js +++ b/public/menu.js @@ -29,6 +29,12 @@ module.exports = (app, mainWindow, newVersion) => { mainWindow.webContents.send('close-file'); }, }, + { + label: i18n.t('Close batch'), + async click() { + mainWindow.webContents.send('close-batch-files'); + }, + }, { type: 'separator' }, { label: i18n.t('Import project (LLC)...'), diff --git a/src/App.jsx b/src/App.jsx index 8174dd0e..4e81c9d9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import React, { memo, useEffect, useState, useCallback, useRef, useMemo } from 'react'; -import { FaAngleLeft, FaWindowClose } from 'react-icons/fa'; +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; +import { FaAngleLeft, FaWindowClose, FaTimes, FaAngleRight, FaFile } from 'react-icons/fa'; import { AnimatePresence, motion } from 'framer-motion'; import Swal from 'sweetalert2'; import Lottie from 'react-lottie-player'; @@ -42,7 +43,7 @@ import ValueTuner from './components/ValueTuner'; import VolumeControl from './components/VolumeControl'; import SubtitleControl from './components/SubtitleControl'; import { loadMifiLink } from './mifi'; -import { primaryColor, controlsBackground } from './colors'; +import { primaryColor, controlsBackground, timelineBackground } from './colors'; import allOutFormats from './outFormats'; import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame'; import { @@ -61,7 +62,7 @@ import { hasDuplicates, havePermissionToReadFile, isMac, resolvePathIfNeeded, pathExists, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, } from './util'; import { formatDuration } from './util/duration'; -import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMergeDialog, showOpenAndMergeDialog, openAbout, showEditableJsonDialog } from './dialogs'; +import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, showMultipleFilesDialog, showOpenAndMergeDialog, openAbout, showEditableJsonDialog } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags } from './segments'; @@ -141,11 +142,14 @@ const App = memo(() => { const [keyframesEnabled, setKeyframesEnabled] = useState(true); const [waveformEnabled, setWaveformEnabled] = useState(false); const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false); - const [showSideBar, setShowSideBar] = useState(true); + const [showRightBar, setShowRightBar] = useState(true); const [hideCanvasPreview, setHideCanvasPreview] = useState(false); const [cleanupChoices, setCleanupChoices] = useState({ tmpFiles: true }); const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); + // Batch state + const [batchFiles, setBatchFiles] = useState([]); + // Segment related state const [currentSegIndex, setCurrentSegIndex] = useState(0); const [cutStartTimeManual, setCutStartTimeManual] = useState(); @@ -243,7 +247,7 @@ const App = memo(() => { }); } - const toggleSideBar = useCallback(() => setShowSideBar(v => !v), []); + const toggleRightBar = useCallback(() => setShowRightBar(v => !v), []); const toggleCopyStreamId = useCallback((path, index) => { setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] })); @@ -935,14 +939,24 @@ const App = memo(() => { }); }, [playing, filePath]); - const closeFile = useCallback(() => { - if (!isFileOpened || workingRef.current) return false; + const closeFileWithConfirm = useCallback(() => { // eslint-disable-next-line no-alert if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the current file?'))) return false; resetState(); return true; - }, [askBeforeClose, isFileOpened, resetState]); + }, [askBeforeClose, resetState]); + + const closeFile = useCallback(() => { + if (!isFileOpened || workingRef.current) return false; + return closeFileWithConfirm(); + }, [closeFileWithConfirm, isFileOpened]); + + const closeBatch = useCallback(() => { + // eslint-disable-next-line no-alert + if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the loaded batch of files?'))) return; + setBatchFiles([]); + }, [askBeforeClose]); const cleanupFiles = useCallback(async () => { // Because we will reset state before deleting files @@ -1285,6 +1299,8 @@ const App = memo(() => { resetState(); + console.log('state reset'); + setWorking(i18n.t('Loading file')); async function checkAndSetExistingHtml5FriendlyFile() { @@ -1397,98 +1413,6 @@ const App = memo(() => { const seekAccelerationRef = useRef(1); - // TODO split up? - useEffect(() => { - if (exportConfirmVisible) return () => {}; - - const togglePlayNoReset = () => togglePlay(); - const togglePlayReset = () => togglePlay(true); - const reducePlaybackRate = () => changePlaybackRate(-1); - const increasePlaybackRate = () => changePlaybackRate(1); - function seekBackwards() { - seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current * -1); - seekAccelerationRef.current *= keyboardSeekAccFactor; - } - function seekForwards() { - seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current); - seekAccelerationRef.current *= keyboardSeekAccFactor; - } - const seekReset = () => { - seekAccelerationRef.current = 1; - }; - const seekBackwardsPercent = () => { seekRelPercent(-0.01); return false; }; - const seekForwardsPercent = () => { seekRelPercent(0.01); return false; }; - const seekBackwardsKeyframe = () => seekClosestKeyframe(-1); - const seekForwardsKeyframe = () => seekClosestKeyframe(1); - const seekBackwardsShort = () => shortStep(-1); - const seekForwardsShort = () => shortStep(1); - const jumpPrevSegment = () => jumpSeg(-1); - const jumpNextSegment = () => jumpSeg(1); - const zoomIn = () => { zoomRel(1); return false; }; - const zoomOut = () => { zoomRel(-1); return false; }; - - // mousetrap seems to be the only lib properly handling layouts that require shift to be pressed to get a particular key #520 - // Also document.addEventListener needs custom handling of modifier keys or C will be triggered by CTRL+C, etc - const mousetrap = new Mousetrap(); - // mousetrap.bind(':', () => console.log('test')); - mousetrap.bind('plus', () => addCutSegment()); - mousetrap.bind('space', () => togglePlayReset()); - mousetrap.bind('k', () => togglePlayNoReset()); - mousetrap.bind('j', () => reducePlaybackRate()); - mousetrap.bind('l', () => increasePlaybackRate()); - mousetrap.bind('z', () => toggleComfortZoom()); - mousetrap.bind(',', () => seekBackwardsShort()); - mousetrap.bind('.', () => seekForwardsShort()); - mousetrap.bind('c', () => capture()); - mousetrap.bind('i', () => setCutStart()); - mousetrap.bind('o', () => setCutEnd()); - mousetrap.bind('backspace', () => removeCutSegment(currentSegIndexSafe)); - mousetrap.bind('d', () => cleanupFiles()); - mousetrap.bind('b', () => splitCurrentSegment()); - mousetrap.bind('r', () => increaseRotation()); - - mousetrap.bind('left', () => seekBackwards()); - mousetrap.bind('left', () => seekReset(), 'keyup'); - mousetrap.bind(['ctrl+left', 'command+left'], () => seekBackwardsPercent()); - mousetrap.bind('alt+left', () => seekBackwardsKeyframe()); - mousetrap.bind('shift+left', () => jumpCutStart()); - - mousetrap.bind('right', () => seekForwards()); - mousetrap.bind('right', () => seekReset(), 'keyup'); - mousetrap.bind(['ctrl+right', 'command+right'], () => seekForwardsPercent()); - mousetrap.bind('alt+right', () => seekForwardsKeyframe()); - mousetrap.bind('shift+right', () => jumpCutEnd()); - - mousetrap.bind('up', () => jumpPrevSegment()); - mousetrap.bind(['ctrl+up', 'command+up'], () => zoomIn()); - - mousetrap.bind('down', () => jumpNextSegment()); - mousetrap.bind(['ctrl+down', 'command+down'], () => zoomOut()); - - // https://github.com/mifi/lossless-cut/issues/610 - Mousetrap.bind(['ctrl+z', 'command+z'], (e) => { - e.preventDefault(); - cutSegmentsHistory.back(); - }); - Mousetrap.bind(['ctrl+shift+z', 'command+shift+z'], (e) => { - e.preventDefault(); - cutSegmentsHistory.forward(); - }); - - mousetrap.bind(['enter'], () => { - onLabelSegmentPress(currentSegIndexSafe); - return false; - }); - - return () => mousetrap.reset(); - }, [ - addCutSegment, capture, changePlaybackRate, togglePlay, removeCutSegment, - setCutEnd, setCutStart, seekRel, seekRelPercent, shortStep, cleanupFiles, jumpSeg, - seekClosestKeyframe, zoomRel, toggleComfortZoom, splitCurrentSegment, exportConfirmVisible, - increaseRotation, jumpCutStart, jumpCutEnd, cutSegmentsHistory, keyboardSeekAccFactor, - keyboardNormalSeekSpeed, onLabelSegmentPress, currentSegIndexSafe, - ]); - useEffect(() => { function onKeyPress() { if (exportConfirmVisible) onExportConfirm(); @@ -1612,6 +1536,32 @@ const App = memo(() => { return false; }, [isFileOpened]); + const batchOpenSingleFile = useCallback(async (path) => { + if (workingRef.current) return; + if (filePath === path) return; + try { + setWorking(i18n.t('Loading file')); + await userOpenSingleFile({ path }); + } catch (err) { + handleError(err); + } finally { + setWorking(); + } + }, [userOpenSingleFile, setWorking, filePath]); + + const batchFileJump = useCallback((direction) => { + const pathIndex = batchFiles.findIndex(({ path }) => path === filePath); + if (pathIndex === -1) return; + const nextFile = batchFiles[pathIndex + direction]; + if (!nextFile) return; + batchOpenSingleFile(nextFile.path); + }, [filePath, batchFiles, batchOpenSingleFile]); + + const batchLoadFiles = useCallback((paths) => { + setBatchFiles(paths.map((path) => ({ path, name: basename(path) }))); + batchOpenSingleFile(paths[0]); + }, [batchOpenSingleFile]); + const userOpenFiles = useCallback(async (filePaths) => { if (!filePaths || filePaths.length < 1) return; @@ -1619,7 +1569,7 @@ const App = memo(() => { console.log(filePaths.join('\n')); if (filePaths.length > 1) { - showMergeDialog(filePaths, mergeFiles); + showMultipleFilesDialog(filePaths, mergeFiles, batchLoadFiles); return; } @@ -1677,7 +1627,7 @@ const App = memo(() => { } finally { setWorking(); } - }, [addStreamSourceFile, checkFileOpened, enableAskForFileOpenAction, isFileOpened, loadEdlFile, mergeFiles, userOpenSingleFile, setWorking]); + }, [addStreamSourceFile, checkFileOpened, enableAskForFileOpenAction, isFileOpened, loadEdlFile, mergeFiles, userOpenSingleFile, setWorking, batchLoadFiles]); const userHtml5ifyCurrentFile = useCallback(async () => { if (!filePath) return; @@ -1708,6 +1658,103 @@ const App = memo(() => { } }, [customOutDir, filePath, html5ifyAndLoad, hasVideo, hasAudio, rememberConvertToSupportedFormat, setWorking]); + + // TODO split up? + useEffect(() => { + if (exportConfirmVisible) return () => {}; + + const togglePlayNoReset = () => togglePlay(); + const togglePlayReset = () => togglePlay(true); + const reducePlaybackRate = () => changePlaybackRate(-1); + const increasePlaybackRate = () => changePlaybackRate(1); + function seekBackwards() { + seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current * -1); + seekAccelerationRef.current *= keyboardSeekAccFactor; + } + function seekForwards() { + seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current); + seekAccelerationRef.current *= keyboardSeekAccFactor; + } + const seekReset = () => { + seekAccelerationRef.current = 1; + }; + const seekBackwardsPercent = () => { seekRelPercent(-0.01); return false; }; + const seekForwardsPercent = () => { seekRelPercent(0.01); return false; }; + const seekBackwardsKeyframe = () => seekClosestKeyframe(-1); + const seekForwardsKeyframe = () => seekClosestKeyframe(1); + const seekBackwardsShort = () => shortStep(-1); + const seekForwardsShort = () => shortStep(1); + const jumpPrevSegment = () => jumpSeg(-1); + const jumpNextSegment = () => jumpSeg(1); + const zoomIn = () => { zoomRel(1); return false; }; + const zoomOut = () => { zoomRel(-1); return false; }; + const batchPreviousFile = () => batchFileJump(-1); + const batchNextFile = () => batchFileJump(1); + + // mousetrap seems to be the only lib properly handling layouts that require shift to be pressed to get a particular key #520 + // Also document.addEventListener needs custom handling of modifier keys or C will be triggered by CTRL+C, etc + const mousetrap = new Mousetrap(); + // mousetrap.bind(':', () => console.log('test')); + mousetrap.bind('plus', () => addCutSegment()); + mousetrap.bind('space', () => togglePlayReset()); + mousetrap.bind('k', () => togglePlayNoReset()); + mousetrap.bind('j', () => reducePlaybackRate()); + mousetrap.bind('l', () => increasePlaybackRate()); + mousetrap.bind('z', () => toggleComfortZoom()); + mousetrap.bind(',', () => seekBackwardsShort()); + mousetrap.bind('.', () => seekForwardsShort()); + mousetrap.bind('c', () => capture()); + mousetrap.bind('i', () => setCutStart()); + mousetrap.bind('o', () => setCutEnd()); + mousetrap.bind('backspace', () => removeCutSegment(currentSegIndexSafe)); + mousetrap.bind('d', () => cleanupFiles()); + mousetrap.bind('b', () => splitCurrentSegment()); + mousetrap.bind('r', () => increaseRotation()); + + mousetrap.bind('left', () => seekBackwards()); + mousetrap.bind('left', () => seekReset(), 'keyup'); + mousetrap.bind(['ctrl+left', 'command+left'], () => seekBackwardsPercent()); + mousetrap.bind('alt+left', () => seekBackwardsKeyframe()); + mousetrap.bind('shift+left', () => jumpCutStart()); + + mousetrap.bind('right', () => seekForwards()); + mousetrap.bind('right', () => seekReset(), 'keyup'); + mousetrap.bind(['ctrl+right', 'command+right'], () => seekForwardsPercent()); + mousetrap.bind('alt+right', () => seekForwardsKeyframe()); + mousetrap.bind('shift+right', () => jumpCutEnd()); + + mousetrap.bind('up', () => jumpPrevSegment()); + mousetrap.bind(['ctrl+up', 'command+up'], () => zoomIn()); + mousetrap.bind(['shift+up'], () => batchPreviousFile()); + + mousetrap.bind('down', () => jumpNextSegment()); + mousetrap.bind(['ctrl+down', 'command+down'], () => zoomOut()); + mousetrap.bind(['shift+down'], () => batchNextFile()); + + // https://github.com/mifi/lossless-cut/issues/610 + Mousetrap.bind(['ctrl+z', 'command+z'], (e) => { + e.preventDefault(); + cutSegmentsHistory.back(); + }); + Mousetrap.bind(['ctrl+shift+z', 'command+shift+z'], (e) => { + e.preventDefault(); + cutSegmentsHistory.forward(); + }); + + mousetrap.bind(['enter'], () => { + onLabelSegmentPress(currentSegIndexSafe); + return false; + }); + + return () => mousetrap.reset(); + }, [ + addCutSegment, capture, changePlaybackRate, togglePlay, removeCutSegment, + setCutEnd, setCutStart, seekRel, seekRelPercent, shortStep, cleanupFiles, jumpSeg, + seekClosestKeyframe, zoomRel, toggleComfortZoom, splitCurrentSegment, exportConfirmVisible, + increaseRotation, jumpCutStart, jumpCutEnd, cutSegmentsHistory, keyboardSeekAccFactor, + keyboardNormalSeekSpeed, onLabelSegmentPress, currentSegIndexSafe, batchFileJump, + ]); + const onVideoError = useCallback(async () => { const { error } = videoRef.current; if (!error) return; @@ -1870,9 +1917,11 @@ const App = memo(() => { const showStreamsSelector = () => setStreamsSelectorShown(true); const openSendReportDialog2 = () => { openSendReportDialogWithState(); }; const closeFile2 = () => { closeFile(); }; + const closeBatch2 = () => { closeBatch(); }; electron.ipcRenderer.on('file-opened', fileOpened); electron.ipcRenderer.on('close-file', closeFile2); + electron.ipcRenderer.on('close-batch-files', closeBatch2); electron.ipcRenderer.on('html5ify', userHtml5ifyCurrentFile); electron.ipcRenderer.on('show-merge-dialog', showOpenAndMergeDialog2); electron.ipcRenderer.on('set-start-offset', setStartOffset); @@ -1896,6 +1945,7 @@ const App = memo(() => { return () => { electron.ipcRenderer.removeListener('file-opened', fileOpened); electron.ipcRenderer.removeListener('close-file', closeFile2); + electron.ipcRenderer.removeListener('close-batch-files', closeBatch2); electron.ipcRenderer.removeListener('html5ify', userHtml5ifyCurrentFile); electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog2); electron.ipcRenderer.removeListener('set-start-offset', setStartOffset); @@ -1920,7 +1970,7 @@ const App = memo(() => { mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, userHtml5ifyCurrentFile, extractAllStreams, userOpenFiles, openSendReportDialogWithState, setWorking, loadEdlFile, cutSegments, apparentCutSegments, edlFilePath, toggleHelp, toggleSettings, ensureOutDirAccessible, html5ifyAndLoad, html5ify, - loadCutSegments, duration, checkFileOpened, loadMedia, fileFormat, reorderSegsByStartTime, closeFile, clearSegments, fixInvalidDuration, invertAllCutSegments, + loadCutSegments, duration, checkFileOpened, loadMedia, fileFormat, reorderSegsByStartTime, closeFile, closeBatch, clearSegments, fixInvalidDuration, invertAllCutSegments, ]); async function showAddStreamSourceDialog() { @@ -2058,7 +2108,8 @@ const App = memo(() => { return () => window.removeEventListener('keydown', keyScrollPreventer); }, []); - const sideBarWidth = showSideBar && isFileOpened ? 200 : 0; + const rightBarWidth = showRightBar && isFileOpened ? 200 : 0; + const leftBarWidth = batchFiles.length > 0 ? 200 : 0; const bottomBarHeight = 96 + ((hasAudio && waveformEnabled) || (hasVideo && thumbnailsEnabled) ? timelineHeight : 0); @@ -2173,7 +2224,7 @@ const App = memo(() => { /> - {!isFileOpened && } + {!isFileOpened && } {working && ( @@ -2210,7 +2261,7 @@ const App = memo(() => { )} -
+
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}