1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-25 11:43:17 +01:00

add initial support for i18n (incomplete) #29

This commit is contained in:
Mikael Finstad 2020-03-19 23:37:38 +08:00
parent 232aea1ad7
commit 9ea2cedeff
23 changed files with 452 additions and 262 deletions

View File

@ -28,6 +28,6 @@
"object-curly-newline": 0, "object-curly-newline": 0,
"arrow-parens": 0, "arrow-parens": 0,
"jsx-a11y/control-has-associated-label": 0, "jsx-a11y/control-has-associated-label": 0,
"react/prop-types": 0, "react/prop-types": 0
} }
} }

View File

@ -51,6 +51,7 @@
"file-url": "^3.0.0", "file-url": "^3.0.0",
"framer-motion": "^1.8.4", "framer-motion": "^1.8.4",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"i18next": "^19.3.2",
"lodash": "^4.17.13", "lodash": "^4.17.13",
"moment": "^2.18.1", "moment": "^2.18.1",
"mousetrap": "^1.6.1", "mousetrap": "^1.6.1",
@ -60,6 +61,7 @@
"react": "^16.12.0", "react": "^16.12.0",
"react-dom": "^16.12.0", "react-dom": "^16.12.0",
"react-hammerjs": "^1.0.1", "react-hammerjs": "^1.0.1",
"react-i18next": "^11.3.3",
"react-icons": "^3.9.0", "react-icons": "^3.9.0",
"react-lottie": "^1.2.3", "react-lottie": "^1.2.3",
"react-scripts": "^3.4.0", "react-scripts": "^3.4.0",
@ -83,6 +85,8 @@
"file-type": "^12.4.0", "file-type": "^12.4.0",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"github-api": "^3.2.2", "github-api": "^3.2.2",
"i18next-electron-language-detector": "^0.0.10",
"i18next-node-fs-backend": "^2.1.3",
"mime-types": "^2.1.14", "mime-types": "^2.1.14",
"read-chunk": "^2.0.0", "read-chunk": "^2.0.0",
"string-to-stream": "^1.1.1", "string-to-stream": "^1.1.1",

View File

@ -0,0 +1,2 @@
{
}

View File

@ -10,6 +10,8 @@ import PQueue from 'p-queue';
import filePathToUrl from 'file-url'; import filePathToUrl from 'file-url';
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import uuid from 'uuid'; import uuid from 'uuid';
import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import fromPairs from 'lodash/fromPairs'; import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp'; import clamp from 'lodash/clamp';
@ -18,6 +20,7 @@ import sortBy from 'lodash/sortBy';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import TopMenu from './TopMenu'; import TopMenu from './TopMenu';
import HelpSheet from './HelpSheet'; import HelpSheet from './HelpSheet';
import SettingsSheet from './SettingsSheet'; import SettingsSheet from './SettingsSheet';
@ -192,6 +195,12 @@ const App = memo(() => {
useEffect(() => configStore.set('autoSaveProjectFile', autoSaveProjectFile), [autoSaveProjectFile]); useEffect(() => configStore.set('autoSaveProjectFile', autoSaveProjectFile), [autoSaveProjectFile]);
const [wheelSensitivity, setWheelSensitivity] = useState(configStore.get('wheelSensitivity')); const [wheelSensitivity, setWheelSensitivity] = useState(configStore.get('wheelSensitivity'));
useEffect(() => configStore.set('wheelSensitivity', wheelSensitivity), [wheelSensitivity]); useEffect(() => configStore.set('wheelSensitivity', wheelSensitivity), [wheelSensitivity]);
const [language, setLanguage] = useState(configStore.get('language'));
useEffect(() => (language === undefined ? configStore.delete('language') : configStore.set('language', language)), [language]);
useEffect(() => {
if (language != null) i18n.changeLanguage(language).catch(console.error);
}, [language]);
// Global state // Global state
const [helpVisible, setHelpVisible] = useState(false); const [helpVisible, setHelpVisible] = useState(false);
@ -238,7 +247,7 @@ const App = memo(() => {
function toggleMute() { function toggleMute() {
setMuted((v) => { setMuted((v) => {
if (!v) toast.fire({ title: 'Muted preview (note that exported file will not be affected)' }); if (!v) toast.fire({ title: i18n.t('Muted preview (note that exported file will not be affected)') });
return !v; return !v;
}); });
} }
@ -498,7 +507,7 @@ const App = memo(() => {
await edlStoreSave(edlFilePath, debouncedCutSegments); await edlStoreSave(edlFilePath, debouncedCutSegments);
lastSavedCutSegmentsRef.current = debouncedCutSegments; lastSavedCutSegmentsRef.current = debouncedCutSegments;
} catch (err) { } catch (err) {
errorToast('Failed to save CSV'); errorToast(i18n.t('Failed to save CSV'));
console.error('Failed to save CSV', err); console.error('Failed to save CSV', err);
} }
} }
@ -570,7 +579,7 @@ const App = memo(() => {
customOutDir, paths, allStreams, customOutDir, paths, allStreams,
}); });
} catch (err) { } catch (err) {
errorToast('Failed to merge files. Make sure they are all of the exact same format and codecs'); errorToast(i18n.t('Failed to merge files. Make sure they are all of the exact same format and codecs'));
console.error('Failed to merge files', err); console.error('Failed to merge files', err);
} finally { } finally {
setWorking(false); setWorking(false);
@ -761,7 +770,7 @@ const App = memo(() => {
useEffect(() => () => waveform && URL.revokeObjectURL(waveform.url), [waveform]); useEffect(() => () => waveform && URL.revokeObjectURL(waveform.url), [waveform]);
function showUnsupportedFileMessage() { function showUnsupportedFileMessage() {
toast.fire({ timer: 10000, icon: 'warning', title: 'This video is not natively supported', text: 'This means that there is no audio in the preview and it has low quality. The final export operation will however be lossless and contains audio!' }); toast.fire({ timer: 10000, icon: 'warning', title: i18n.t('This video is not natively supported'), text: i18n.t('This means that there is no audio in the preview and it has low quality. The final export operation will however be lossless and contains audio!') });
} }
const createDummyVideo = useCallback(async (fp) => { const createDummyVideo = useCallback(async (fp) => {
@ -779,7 +788,7 @@ const App = memo(() => {
await createDummyVideo(filePath); await createDummyVideo(filePath);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
errorToast('Failed to playback this file. Try to convert to friendly format from the menu'); errorToast(i18n.t('Failed to playback this file. Try to convert to friendly format from the menu'));
} finally { } finally {
setWorking(false); setWorking(false);
} }
@ -807,7 +816,7 @@ const App = memo(() => {
if (!filePath) return; if (!filePath) return;
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (working || !window.confirm(`Are you sure you want to move the source file to trash? ${filePath}`)) return; if (working || !window.confirm(`${i18n.t('Are you sure you want to move the source file to trash?')} ${filePath}`)) return;
try { try {
setWorking(true); setWorking(true);
@ -815,7 +824,7 @@ const App = memo(() => {
await trash(filePath); await trash(filePath);
if (html5FriendlyPath) await trash(html5FriendlyPath); if (html5FriendlyPath) await trash(html5FriendlyPath);
} catch (err) { } catch (err) {
toast.fire({ icon: 'error', title: `Failed to trash source file: ${err.message}` }); toast.fire({ icon: 'error', title: `${i18n.t('Failed to trash source file:')} ${err.message}` });
} finally { } finally {
resetState(); resetState();
} }
@ -826,27 +835,27 @@ const App = memo(() => {
const cutClick = useCallback(async () => { const cutClick = useCallback(async () => {
if (working) { if (working) {
errorToast('I\'m busy'); errorToast(i18n.t('I\'m busy'));
return; return;
} }
if (haveInvalidSegs) { if (haveInvalidSegs) {
errorToast('Start time must be before end time'); errorToast(i18n.t('Start time must be before end time'));
return; return;
} }
if (numStreamsToCopy === 0) { if (numStreamsToCopy === 0) {
errorToast('No tracks to export!'); errorToast(i18n.t('No tracks to export!'));
return; return;
} }
if (!outSegments) { if (!outSegments) {
errorToast('No segments to export!'); errorToast(i18n.t('No segments to export!'));
return; return;
} }
if (outSegments.length < 1) { if (outSegments.length < 1) {
errorToast('No segments to export'); errorToast(i18n.t('No segments to export'));
return; return;
} }
@ -888,7 +897,8 @@ const App = memo(() => {
} }
} }
toast.fire({ timer: 10000, icon: 'success', title: `Export completed! Go to settings to view the ffmpeg commands that were executed. If output does not look right, try to toggle "Keyframe cut" or try a different output format (e.g. matroska). Output file(s) can be found at: ${outputDir}.${exportExtraStreams ? ' Extra unprocessable streams were exported to separate files.' : ''}` }); const extraStreamsMsg = exportExtraStreams ? ` ${i18n.t('Extra unprocessable streams were exported to separate files.')}` : '';
toast.fire({ timer: 10000, icon: 'success', title: `${i18n.t('Export completed! Go to settings to view the ffmpeg commands that were executed. If output does not look right, try to toggle "Keyframe cut" or try a different output format (e.g. matroska). Output file(s) can be found at:')} ${outputDir}.${extraStreamsMsg}` });
} catch (err) { } catch (err) {
console.error('stdout:', err.stdout); console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr); console.error('stderr:', err.stderr);
@ -913,15 +923,15 @@ const App = memo(() => {
const capture = useCallback(async () => { const capture = useCallback(async () => {
if (!filePath) return; if (!filePath) return;
if (html5FriendlyPath || dummyVideoPath) { if (html5FriendlyPath || dummyVideoPath) {
errorToast('Capture frame from this video not yet implemented'); errorToast(i18n.t('Capture frame from this video not yet implemented'));
return; return;
} }
try { try {
const outPath = await captureFrame(customOutDir, filePath, videoRef.current, currentTimeRef.current, captureFormat); const outPath = await captureFrame(customOutDir, filePath, videoRef.current, currentTimeRef.current, captureFormat);
toast.fire({ icon: 'success', title: `Screenshot captured to: ${outPath}` }); toast.fire({ icon: 'success', title: `${i18n.t('Screenshot captured to:')} ${outPath}` });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
errorToast('Failed to capture frame'); errorToast(i18n.t('Failed to capture frame'));
} }
}, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath]); }, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath]);
@ -955,7 +965,7 @@ const App = memo(() => {
.every(row => row.start === undefined || row.end === undefined || row.start < row.end); .every(row => row.start === undefined || row.end === undefined || row.start < row.end);
if (!allRowsValid) { if (!allRowsValid) {
throw new Error('Invalid start or end values for one or more segments'); throw new Error(i18n.t('Invalid start or end values for one or more segments'));
} }
cutSegmentsHistory.go(0); cutSegmentsHistory.go(0);
@ -963,7 +973,7 @@ const App = memo(() => {
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') { if (err.code !== 'ENOENT') {
console.error('EDL load failed', err); console.error('EDL load failed', err);
errorToast(`Failed to load EDL file (${err.message})`); errorToast(`${i18n.t('Failed to load EDL file')} (${err.message})`);
} }
} }
}, [cutSegmentsHistory, setCutSegments]); }, [cutSegmentsHistory, setCutSegments]);
@ -971,7 +981,7 @@ const App = memo(() => {
const load = useCallback(async (fp, html5FriendlyPathRequested) => { const load = useCallback(async (fp, html5FriendlyPathRequested) => {
console.log('Load', { fp, html5FriendlyPathRequested }); console.log('Load', { fp, html5FriendlyPathRequested });
if (working) { if (working) {
errorToast('Tried to load file while busy'); errorToast(i18n.t('Tried to load file while busy'));
return; return;
} }
@ -984,7 +994,7 @@ const App = memo(() => {
const ff = await getDefaultOutFormat(fp, fd); const ff = await getDefaultOutFormat(fp, fd);
if (!ff) { if (!ff) {
errorToast('Unable to determine file format'); errorToast(i18n.t('Unable to determine file format'));
return; return;
} }
@ -1023,7 +1033,7 @@ const App = memo(() => {
await loadEdlFile(getEdlFilePath(fp)); await loadEdlFile(getEdlFilePath(fp));
} catch (err) { } catch (err) {
if (err.exitCode === 1 || err.code === 'ENOENT') { if (err.exitCode === 1 || err.code === 'ENOENT') {
errorToast('Unsupported file'); errorToast(i18n.t('Unsupported file'));
console.error(err); console.error(err);
return; return;
} }
@ -1124,9 +1134,9 @@ const App = memo(() => {
try { try {
setWorking(true); setWorking(true);
await extractStreams({ customOutDir, filePath, streams: mainStreams }); await extractStreams({ customOutDir, filePath, streams: mainStreams });
toast.fire({ icon: 'success', title: `All streams can be found as separate files at: ${outputDir}` }); toast.fire({ icon: 'success', title: `${i18n.t('All streams can be found as separate files at:')} ${outputDir}` });
} catch (err) { } catch (err) {
errorToast('Failed to extract all streams'); errorToast(i18n.t('Failed to extract all streams'));
console.error('Failed to extract all streams', err); console.error('Failed to extract all streams', err);
} finally { } finally {
setWorking(false); setWorking(false);
@ -1160,16 +1170,16 @@ const App = memo(() => {
return; return;
} }
const { value } = await Swal.fire({ const { value } = await Swal.fire({
title: 'You opened a new file. What do you want to do?', title: i18n.t('You opened a new file. What do you want to do?'),
icon: 'question', icon: 'question',
input: 'radio', input: 'radio',
inputValue: 'open', inputValue: 'open',
showCancelButton: true, showCancelButton: true,
inputOptions: { inputOptions: {
open: 'Open the file instead of the current one. You will lose all unsaved work', open: i18n.t('Open the file instead of the current one. You will lose all unsaved work'),
add: 'Include all tracks from the new file', add: i18n.t('Include all tracks from the new file'),
}, },
inputValidator: (v) => !v && 'You need to choose something!', inputValidator: (v) => !v && i18n.t('You need to choose something!'),
}); });
if (value === 'open') { if (value === 'open') {
@ -1200,7 +1210,7 @@ const App = memo(() => {
function closeFile() { function closeFile() {
if (!isFileOpened) return; if (!isFileOpened) return;
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (askBeforeClose && !window.confirm('Are you sure you want to close the current file? You will lose all unsaved work')) return; if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the current file? You will lose all unsaved work'))) return;
resetState(); resetState();
} }
@ -1220,7 +1230,7 @@ const App = memo(() => {
await createDummyVideo(filePath); await createDummyVideo(filePath);
} }
} catch (err) { } catch (err) {
errorToast('Failed to html5ify file'); errorToast(i18n.t('Failed to html5ify file'));
console.error('Failed to html5ify file', err); console.error('Failed to html5ify file', err);
} finally { } finally {
setWorking(false); setWorking(false);
@ -1255,22 +1265,22 @@ const App = memo(() => {
async function exportEdlFile() { async function exportEdlFile() {
try { try {
const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.csv`, filters: [{ name: 'CSV files', extensions: ['csv'] }] }); const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.csv`, filters: [{ name: i18n.t('CSV files'), extensions: ['csv'] }] });
if (canceled || !fp) return; if (canceled || !fp) return;
if (await exists(fp)) { if (await exists(fp)) {
errorToast('File exists, bailing'); errorToast(i18n.t('File exists, bailing'));
return; return;
} }
await edlStoreSave(fp, cutSegments); await edlStoreSave(fp, cutSegments);
} catch (err) { } catch (err) {
errorToast('Failed to export CSV'); errorToast(i18n.t('Failed to export CSV'));
console.error('Failed to export CSV', err); console.error('Failed to export CSV', err);
} }
} }
async function importEdlFile() { async function importEdlFile() {
if (!isFileOpened) return; if (!isFileOpened) return;
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'CSV files', extensions: ['csv'] }] }); const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: i18n.t('CSV files'), extensions: ['csv'] }] });
if (canceled || filePaths.length < 1) return; if (canceled || filePaths.length < 1) return;
await loadEdlFile(filePaths[0]); await loadEdlFile(filePaths[0]);
} }
@ -1342,26 +1352,26 @@ const App = memo(() => {
const renderOutFmt = useCallback((props) => ( const renderOutFmt = useCallback((props) => (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<Select value={fileFormat || ''} title="Output format" onChange={withBlur(e => setFileFormat(e.target.value))} {...props}> <Select value={fileFormat || ''} title={i18n.t('Output format')} onChange={withBlur(e => setFileFormat(e.target.value))} {...props}>
<option key="disabled1" value="" disabled>Format</option> <option key="disabled1" value="" disabled>{i18n.t('Format')}</option>
{detectedFileFormat && ( {detectedFileFormat && (
<option key={detectedFileFormat} value={detectedFileFormat}> <option key={detectedFileFormat} value={detectedFileFormat}>
{detectedFileFormat} - {allOutFormats[detectedFileFormat]} (detected) {detectedFileFormat} - {allOutFormats[detectedFileFormat]} {i18n.t('(detected)')}
</option> </option>
)} )}
<option key="disabled2" value="" disabled>--- Common formats: ---</option> <option key="disabled2" value="" disabled>--- {i18n.t('Common formats:')} ---</option>
{renderFormatOptions(commonFormatsMap)} {renderFormatOptions(commonFormatsMap)}
<option key="disabled3" value="" disabled>--- All formats: ---</option> <option key="disabled3" value="" disabled>--- {i18n.t('All formats:')} ---</option>
{renderFormatOptions(otherFormatsMap)} {renderFormatOptions(otherFormatsMap)}
</Select> </Select>
), [commonFormatsMap, detectedFileFormat, fileFormat, otherFormatsMap]); ), [commonFormatsMap, detectedFileFormat, fileFormat, otherFormatsMap]);
const renderCaptureFormatButton = useCallback((props) => ( const renderCaptureFormatButton = useCallback((props) => (
<Button <Button
title="Capture frame format" title={i18n.t('Capture frame format')}
onClick={withBlur(toggleCaptureFormat)} onClick={withBlur(toggleCaptureFormat)}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
@ -1372,14 +1382,13 @@ const App = memo(() => {
const AutoExportToggler = useCallback(() => ( const AutoExportToggler = useCallback(() => (
<SegmentedControl <SegmentedControl
options={[{ label: 'Extract', value: 'extract' }, { label: 'Discard', value: 'discard' }]} options={[{ label: i18n.t('Extract'), value: 'extract' }, { label: i18n.t('Discard'), value: 'discard' }]}
value={autoExportExtraStreams ? 'extract' : 'discard'} value={autoExportExtraStreams ? 'extract' : 'discard'}
onChange={value => setAutoExportExtraStreams(value === 'extract')} onChange={value => setAutoExportExtraStreams(value === 'extract')}
/> />
), [autoExportExtraStreams]); ), [autoExportExtraStreams]);
const onWheelTunerRequested = useCallback(() => { const onWheelTunerRequested = useCallback(() => {
console.log('wat');
setSettingsVisible(false); setSettingsVisible(false);
setWheelTunerVisible(true); setWheelTunerVisible(true);
}, []); }, []);
@ -1400,13 +1409,15 @@ const App = memo(() => {
setTimecodeShowFrames={setTimecodeShowFrames} setTimecodeShowFrames={setTimecodeShowFrames}
askBeforeClose={askBeforeClose} askBeforeClose={askBeforeClose}
setAskBeforeClose={setAskBeforeClose} setAskBeforeClose={setAskBeforeClose}
language={language}
setLanguage={setLanguage}
renderOutFmt={renderOutFmt} renderOutFmt={renderOutFmt}
AutoExportToggler={AutoExportToggler} AutoExportToggler={AutoExportToggler}
renderCaptureFormatButton={renderCaptureFormatButton} renderCaptureFormatButton={renderCaptureFormatButton}
onWheelTunerRequested={onWheelTunerRequested} onWheelTunerRequested={onWheelTunerRequested}
/> />
), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, renderOutFmt, timecodeShowFrames, setOutputDir, onWheelTunerRequested]); ), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, renderOutFmt, timecodeShowFrames, setOutputDir, onWheelTunerRequested, language]);
useEffect(() => { useEffect(() => {
loadMifiLink().then(setMifiLink); loadMifiLink().then(setMifiLink);
@ -1442,6 +1453,8 @@ const App = memo(() => {
if (thumbnailsEnabled) timelineMode = 'thumbnails'; if (thumbnailsEnabled) timelineMode = 'thumbnails';
if (waveformEnabled) timelineMode = 'waveform'; if (waveformEnabled) timelineMode = 'waveform';
const { t } = useTranslation();
return ( return (
<div> <div>
<div className="no-user-select" style={{ background: controlsBackground, height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between', flexWrap: 'wrap' }}> <div className="no-user-select" style={{ background: controlsBackground, height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between', flexWrap: 'wrap' }}>
@ -1493,7 +1506,7 @@ const App = memo(() => {
{!isFileOpened && ( {!isFileOpened && (
<div className="no-user-select" style={{ position: 'fixed', left: 0, right: 0, top: topBarHeight, bottom: bottomBarHeight, border: '2vmin dashed #252525', color: '#505050', margin: '5vmin', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', whiteSpace: 'nowrap' }}> <div className="no-user-select" style={{ position: 'fixed', left: 0, right: 0, top: topBarHeight, bottom: bottomBarHeight, border: '2vmin dashed #252525', color: '#505050', margin: '5vmin', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', whiteSpace: 'nowrap' }}>
<div style={{ fontSize: '9vmin' }}>DROP VIDEO(S)</div> <div style={{ fontSize: '9vmin', textTransform: 'uppercase' }}>{t('DROP FILE(S)')}</div>
{mifiLink && mifiLink.loadUrl && ( {mifiLink && mifiLink.loadUrl && (
<div style={{ position: 'relative', margin: '3vmin', width: '60vmin', height: '20vmin' }}> <div style={{ position: 'relative', margin: '3vmin', width: '60vmin', height: '20vmin' }}>
@ -1525,7 +1538,7 @@ const App = memo(() => {
</div> </div>
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
WORKING {t('WORKING')}
</div> </div>
{(cutProgress != null) && ( {(cutProgress != null) && (
@ -1568,7 +1581,7 @@ const App = memo(() => {
position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: sideBarWidth, color: 'white', position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: sideBarWidth, color: 'white',
}} }}
> >
Lossless rotation preview {t('Lossless rotation preview')}
</div> </div>
)} )}
@ -1581,7 +1594,7 @@ const App = memo(() => {
}} }}
> >
<VolumeIcon <VolumeIcon
title="Mute preview? (will not affect output)" title={t('Mute preview? (will not affect output)')}
size={30} size={30}
role="button" role="button"
style={{ margin: '0 10px 10px 10px' }} style={{ margin: '0 10px 10px 10px' }}
@ -1590,7 +1603,7 @@ const App = memo(() => {
{!showSideBar && ( {!showSideBar && (
<FaAngleLeft <FaAngleLeft
title="Show sidebar" title={t('Show sidebar')}
size={30} size={30}
role="button" role="button"
style={{ margin: '0 10px 10px 10px' }} style={{ margin: '0 10px 10px 10px' }}
@ -1729,9 +1742,9 @@ const App = memo(() => {
{wheelTunerVisible && ( {wheelTunerVisible && (
<div style={{ display: 'flex', alignItems: 'center', background: 'white', color: 'black', padding: 10, margin: 10, borderRadius: 10, width: '100%', maxWidth: 500, position: 'fixed', left: 0, bottom: bottomBarHeight, zIndex: 10 }}> <div style={{ display: 'flex', alignItems: 'center', background: 'white', color: 'black', padding: 10, margin: 10, borderRadius: 10, width: '100%', maxWidth: 500, position: 'fixed', left: 0, bottom: bottomBarHeight, zIndex: 10 }}>
Scroll sensitivity {t('Scroll sensitivity')}
<input style={{ flexGrow: 1 }} type="range" min="0" max="1000" step="1" value={wheelSensitivity * 1000} onChange={e => setWheelSensitivity(e.target.value / 1000)} /> <input style={{ flexGrow: 1 }} type="range" min="0" max="1000" step="1" value={wheelSensitivity * 1000} onChange={e => setWheelSensitivity(e.target.value / 1000)} />
<Button height={20} intent="success" onClick={() => setWheelTunerVisible(false)}>Done</Button> <Button height={20} intent="success" onClick={() => setWheelTunerVisible(false)}>{t('Done')}</Button>
</div> </div>
)} )}
</div> </div>

View File

@ -2,6 +2,7 @@ import React, { memo } from 'react';
import { IoIosCloseCircleOutline } from 'react-icons/io'; import { IoIosCloseCircleOutline } from 'react-icons/io';
import { FaClipboard } from 'react-icons/fa'; import { FaClipboard } from 'react-icons/fa';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { toast } from './util'; import { toast } from './util';
@ -10,74 +11,79 @@ const { clipboard } = window.require('electron');
const HelpSheet = memo(({ const HelpSheet = memo(({
visible, onTogglePress, ffmpegCommandLog, visible, onTogglePress, ffmpegCommandLog,
}) => ( }) => {
<AnimatePresence> const { t } = useTranslation();
{visible && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="help-sheet"
>
<IoIosCloseCircleOutline role="button" onClick={onTogglePress} size={30} style={{ position: 'fixed', right: 0, top: 0, padding: 20 }} />
<h1>Keyboard shortcuts</h1> return (
<div><kbd>H</kbd> Show/hide this screen</div> <AnimatePresence>
{visible && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="help-sheet"
>
<IoIosCloseCircleOutline role="button" onClick={onTogglePress} size={30} style={{ position: 'fixed', right: 0, top: 0, padding: 20 }} />
<h2>Playback</h2> <h1>{t('Keyboard shortcuts')}</h1>
<div><kbd>SPACE</kbd>, <kbd>k</kbd> Play/pause</div> <div><kbd>H</kbd> {t('Show/hide this screen')}</div>
<div><kbd>J</kbd> Slow down playback</div>
<div><kbd>L</kbd> Speed up playback</div>
<h2>Seeking</h2> <h2>{t('Playback')}</h2>
<div><kbd>,</kbd> Step backward 1 frame</div>
<div><kbd>.</kbd> Step forward 1 frame</div>
<div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> Seek to previous keyframe</div>
<div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> Seek to next keyframe</div>
<div><kbd></kbd> Seek backward 1 sec</div>
<div><kbd></kbd> Seek forward 1 sec</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> Seek backward 1% of timeline at current zoom</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> Seek forward 1% of timeline at current zoom</div>
<h2>Timeline/zoom operations</h2> <div><kbd>SPACE</kbd>, <kbd>k</kbd> {t('Play/pause')}</div>
<div><kbd>Z</kbd> Toggle zoom between 1x and a calculated comfortable zoom level</div> <div><kbd>J</kbd> {t('Slow down playback')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> Zoom in timeline</div> <div><kbd>L</kbd> {t('Speed up playback')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> Zoom out timeline</div>
<div><kbd>CTRL</kbd> <i>+ Mouse scroll/wheel up/down</i> - Zoom in/out timeline</div>
<div><i>Mouse scroll/wheel left/right</i> - Pan timeline</div>
<div style={{ marginLeft: 20 }}>(For Windows or computers without 2D track pad or left/right mouse wheel scrolling function, use <kbd>SHIFT</kbd> <i>+ Mouse scroll/wheel up/down</i>)</div>
<h2>Segments and cut points</h2> <h2>{t('Seeking')}</h2>
<div><kbd>I</kbd> Mark in / cut start point for current segment</div>
<div><kbd>O</kbd> Mark out / cut end point for current segment</div>
<div><kbd>+</kbd> Add cut segment</div>
<div><kbd>BACKSPACE</kbd> Remove current segment</div>
<div><kbd></kbd> Select previous segment</div>
<div><kbd></kbd> Select next segment</div>
<h2>File system actions</h2> <div><kbd>,</kbd> {t('Step backward 1 frame')}</div>
<div><kbd>E</kbd> Export segment(s)</div> <div><kbd>.</kbd> {t('Step forward 1 frame')}</div>
<div><kbd>C</kbd> Capture snapshot</div> <div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> {t('Seek to previous keyframe')}</div>
<div><kbd>D</kbd> Delete source file</div> <div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> {t('Seek to next keyframe')}</div>
<div><kbd></kbd> {t('Seek backward 1 sec')}</div>
<div><kbd></kbd> {t('Seek forward 1 sec')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Seek backward 1% of timeline at current zoom')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Seek forward 1% of timeline at current zoom')}</div>
<p style={{ fontWeight: 'bold' }}>Hover mouse over buttons in the main interface to see which function they have.</p> <h2>{t('Timeline/zoom operations')}</h2>
<div><kbd>Z</kbd> {t('Toggle zoom between 1x and a calculated comfortable zoom level')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Zoom in timeline')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Zoom out timeline')}</div>
<div><kbd>CTRL</kbd> <i>+ {t('Mouse scroll/wheel up/down')}</i> - {t('Zoom in/out timeline')}</div>
<div><i>{t('Mouse scroll/wheel left/right')}</i> - {t('Pan timeline')}</div>
<h1 style={{ marginTop: 40 }}>Last ffmpeg commands</h1> <h2>{t('Segments and cut points')}</h2>
{ffmpegCommandLog.length > 0 ? ( <div><kbd>I</kbd> {t('Mark in / cut start point for current segment')}</div>
<div style={{ overflowY: 'scroll', height: 200 }}> <div><kbd>O</kbd> {t('Mark out / cut end point for current segment')}</div>
{ffmpegCommandLog.reverse().map(({ command }, i) => ( <div><kbd>+</kbd> {t('Add cut segment')}</div>
// eslint-disable-next-line react/no-array-index-key <div><kbd>BACKSPACE</kbd> {t('Remove current segment')}</div>
<div key={i} style={{ whiteSpace: 'pre', margin: '5px 0' }}> <div><kbd></kbd> {t('Select previous segment')}</div>
<FaClipboard style={{ cursor: 'pointer' }} title="Copy to clipboard" onClick={() => { clipboard.writeText(command); toast.fire({ timer: 2000, icon: 'success', title: 'Copied to clipboard' }); }} /> {command} <div><kbd></kbd> {t('Select next segment')}</div>
</div>
))} <h2>{t('File system actions')}</h2>
</div> <div><kbd>E</kbd> {t('Export segment(s)')}</div>
) : ( <div><kbd>C</kbd> {t('Capture snapshot')}</div>
<p>The last executed ffmpeg commands will show up here after you run operations. You can copy them to clipboard and modify them to your needs before running on your command line.</p> <div><kbd>D</kbd> {t('Delete source file')}</div>
)}
</motion.div> <p style={{ fontWeight: 'bold' }}>{t('Hover mouse over buttons in the main interface to see which function they have')}</p>
)}
</AnimatePresence> <h1 style={{ marginTop: 40 }}>{t('Last ffmpeg commands')}</h1>
)); {ffmpegCommandLog.length > 0 ? (
<div style={{ overflowY: 'scroll', height: 200 }}>
{ffmpegCommandLog.reverse().map(({ command }, i) => (
// eslint-disable-next-line react/no-array-index-key
<div key={i} style={{ whiteSpace: 'pre', margin: '5px 0' }}>
<FaClipboard style={{ cursor: 'pointer' }} title={t('Copy to clipboard')} onClick={() => { clipboard.writeText(command); toast.fire({ timer: 2000, icon: 'success', title: t('Copied to clipboard') }); }} /> {command}
</div>
))}
</div>
) : (
<p>{t('The last executed ffmpeg commands will show up here after you run operations. You can copy them to clipboard and modify them to your needs before running on your command line.')}</p>
)}
</motion.div>
)}
</AnimatePresence>
);
});
export default HelpSheet; export default HelpSheet;

View File

@ -2,16 +2,19 @@ import React, { memo } from 'react';
import { Select } from 'evergreen-ui'; import { Select } from 'evergreen-ui';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { FaYinYang } from 'react-icons/fa'; import { FaYinYang } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { withBlur, toast } from './util'; import { withBlur, toast } from './util';
const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments, toggleComfortZoom }) => { const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments, toggleComfortZoom }) => {
const { t } = useTranslation();
function onYinYangClick() { function onYinYangClick() {
setInvertCutSegments(v => { setInvertCutSegments(v => {
const newVal = !v; const newVal = !v;
if (newVal) toast.fire({ title: 'When you export, selected segments on the timeline will be REMOVED - the surrounding areas will be KEPT' }); if (newVal) toast.fire({ title: t('When you export, selected segments on the timeline will be REMOVED - the surrounding areas will be KEPT') });
else toast.fire({ title: 'When you export, selected segments on the timeline will be KEPT - the surrounding areas will be REMOVED.' }); else toast.fire({ title: t('When you export, selected segments on the timeline will be KEPT - the surrounding areas will be REMOVED.') });
return newVal; return newVal;
}); });
} }
@ -28,18 +31,18 @@ const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments,
<FaYinYang <FaYinYang
size={26} size={26}
role="button" role="button"
title={invertCutSegments ? 'Discard selected segments' : 'Keep selected segments'} title={invertCutSegments ? t('Discard selected segments') : t('Keep selected segments')}
onClick={onYinYangClick} onClick={onYinYangClick}
/> />
</motion.div> </motion.div>
</div> </div>
<div role="button" style={{ marginRight: 5, marginLeft: 10 }} title="Zoom" onClick={toggleComfortZoom}>{Math.floor(zoom)}x</div> <div role="button" style={{ marginRight: 5, marginLeft: 10 }} title={t('Zoom')} onClick={toggleComfortZoom}>{Math.floor(zoom)}x</div>
<Select height={20} style={{ width: 20 }} value={zoomOptions.includes(zoom) ? zoom.toString() : ''} title="Zoom" onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}> <Select height={20} style={{ width: 20 }} value={zoomOptions.includes(zoom) ? zoom.toString() : ''} title={t('Zoom')} onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
<option key="" value="" disabled>Zoom</option> <option key="" value="" disabled>{t('Zoom')}</option>
{zoomOptions.map(val => ( {zoomOptions.map(val => (
<option key={val} value={String(val)}>Zoom {val}x</option> <option key={val} value={String(val)}>{t('Zoom')} {val}x</option>
))} ))}
</Select> </Select>
</div> </div>

View File

@ -3,6 +3,7 @@ import { IoIosCamera } from 'react-icons/io';
import { FaTrashAlt, FaFileExport } from 'react-icons/fa'; import { FaTrashAlt, FaFileExport } from 'react-icons/fa';
import { MdRotate90DegreesCcw } from 'react-icons/md'; import { MdRotate90DegreesCcw } from 'react-icons/md';
import { FiScissors } from 'react-icons/fi'; import { FiScissors } from 'react-icons/fi';
import { useTranslation } from 'react-i18next';
import { primaryColor } from './colors'; import { primaryColor } from './colors';
@ -14,6 +15,8 @@ const RightMenu = memo(({
const rotationStr = `${rotation}°`; const rotationStr = `${rotation}°`;
const CutIcon = areWeCutting ? FiScissors : FaFileExport; const CutIcon = areWeCutting ? FiScissors : FaFileExport;
const { t } = useTranslation();
return ( return (
<div className="no-user-select" style={{ padding: '.3em', display: 'flex', alignItems: 'center' }}> <div className="no-user-select" style={{ padding: '.3em', display: 'flex', alignItems: 'center' }}>
<div> <div>
@ -21,14 +24,14 @@ const RightMenu = memo(({
<MdRotate90DegreesCcw <MdRotate90DegreesCcw
size={26} size={26}
style={{ margin: '0 5px', verticalAlign: 'middle' }} style={{ margin: '0 5px', verticalAlign: 'middle' }}
title={`Set output rotation. Current: ${isRotationSet ? rotationStr : 'Don\'t modify'}`} title={`${t('Set output rotation. Current: ')} ${isRotationSet ? rotationStr : t('Don\'t modify')}`}
onClick={increaseRotation} onClick={increaseRotation}
role="button" role="button"
/> />
</div> </div>
<FaTrashAlt <FaTrashAlt
title="Delete source file" title={t('Delete source file')}
style={{ padding: '5px 10px' }} style={{ padding: '5px 10px' }}
size={16} size={16}
onClick={deleteSource} onClick={deleteSource}
@ -40,21 +43,21 @@ const RightMenu = memo(({
<IoIosCamera <IoIosCamera
style={{ paddingLeft: 5, paddingRight: 15 }} style={{ paddingLeft: 5, paddingRight: 15 }}
size={25} size={25}
title="Capture frame" title={t('Capture frame')}
onClick={capture} onClick={capture}
/> />
<span <span
style={{ background: primaryColor, borderRadius: 5, padding: '3px 7px', fontSize: 14 }} style={{ background: primaryColor, borderRadius: 5, padding: '3px 7px', fontSize: 14 }}
onClick={cutClick} onClick={cutClick}
title={multipleCutSegments ? 'Export all segments' : 'Export selection'} title={multipleCutSegments ? t('Export all segments') : t('Export selection')}
role="button" role="button"
> >
<CutIcon <CutIcon
style={{ verticalAlign: 'middle', marginRight: 3 }} style={{ verticalAlign: 'middle', marginRight: 3 }}
size={16} size={16}
/> />
Export {t('Export')}
</span> </span>
</div> </div>
); );

View File

@ -3,6 +3,7 @@ import prettyMs from 'pretty-ms';
import { FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown, FaAngleRight } from 'react-icons/fa'; import { FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown, FaAngleRight } from 'react-icons/fa';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import { useTranslation } from 'react-i18next';
import { saveColor } from './colors'; import { saveColor } from './colors';
import { getSegColors } from './util'; import { getSegColors } from './util';
@ -13,12 +14,14 @@ const SegmentList = memo(({
updateCurrentSegOrder, addCutSegment, removeCutSegment, updateCurrentSegOrder, addCutSegment, removeCutSegment,
setCurrentSegmentName, currentCutSeg, toggleSideBar, setCurrentSegmentName, currentCutSeg, toggleSideBar,
}) => { }) => {
const { t } = useTranslation();
if (!cutSegments && invertCutSegments) { if (!cutSegments && invertCutSegments) {
return <div style={{ padding: '0 10px' }}>Make sure you have no overlapping segments.</div>; return <div style={{ padding: '0 10px' }}>{t('Make sure you have no overlapping segments.')}</div>;
} }
if (!cutSegments || cutSegments.length === 0) { if (!cutSegments || cutSegments.length === 0) {
return <div style={{ padding: '0 10px' }}>No segments to export.</div>; return <div style={{ padding: '0 10px' }}>{t('No segments to export.')}</div>;
} }
const { segActiveBgColor: currentSegActiveBgColor } = getSegColors(currentCutSeg); const { segActiveBgColor: currentSegActiveBgColor } = getSegColors(currentCutSeg);
@ -26,12 +29,12 @@ const SegmentList = memo(({
async function onLabelSegmentPress() { async function onLabelSegmentPress() {
const { value } = await Swal.fire({ const { value } = await Swal.fire({
showCancelButton: true, showCancelButton: true,
title: 'Label current segment', title: t('Label current segment'),
inputValue: currentCutSeg.name, inputValue: currentCutSeg.name,
input: 'text', input: 'text',
inputValidator: (v) => { inputValidator: (v) => {
const maxLength = 100; const maxLength = 100;
return v.length > maxLength ? `Max length ${maxLength}` : undefined; return v.length > maxLength ? `${t('Max length')} ${maxLength}` : undefined;
}, },
}); });
@ -41,14 +44,14 @@ const SegmentList = memo(({
async function onReorderSegsPress() { async function onReorderSegsPress() {
if (cutSegments.length < 2) return; if (cutSegments.length < 2) return;
const { value } = await Swal.fire({ const { value } = await Swal.fire({
title: `Change order of segment ${currentSegIndex + 1}`, title: `${t('Change order of segment')} ${currentSegIndex + 1}`,
text: `Please enter a number from 1 to ${cutSegments.length} to be the new order for the current segment`, text: `Please enter a number from 1 to ${cutSegments.length} to be the new order for the current segment`,
input: 'text', input: 'text',
inputValue: currentSegIndex + 1, inputValue: currentSegIndex + 1,
showCancelButton: true, showCancelButton: true,
inputValidator: (v) => { inputValidator: (v) => {
const parsed = parseInt(v, 10); const parsed = parseInt(v, 10);
return Number.isNaN(parsed) || parsed > cutSegments.length || parsed < 1 ? 'Invalid number entered' : undefined; return Number.isNaN(parsed) || parsed > cutSegments.length || parsed < 1 ? t('Invalid number entered') : undefined;
}, },
}); });
@ -63,14 +66,14 @@ const SegmentList = memo(({
<div style={{ padding: '0 10px', overflowY: 'scroll', flexGrow: 1 }} className="hide-scrollbar"> <div style={{ padding: '0 10px', overflowY: 'scroll', flexGrow: 1 }} className="hide-scrollbar">
<div style={{ fontSize: 14, marginBottom: 10 }}> <div style={{ fontSize: 14, marginBottom: 10 }}>
<FaAngleRight <FaAngleRight
title="Close sidebar" title={t('Close sidebar')}
size={18} size={18}
style={{ verticalAlign: 'middle', color: 'white' }} style={{ verticalAlign: 'middle', color: 'white' }}
role="button" role="button"
onClick={toggleSideBar} onClick={toggleSideBar}
/> />
Segments to export: {t('Segments to export:')}
</div> </div>
{cutSegments.map((seg, index) => { {cutSegments.map((seg, index) => {
@ -109,7 +112,7 @@ const SegmentList = memo(({
</div> </div>
<div style={{ fontSize: 12, color: 'white' }}>{seg.name}</div> <div style={{ fontSize: 12, color: 'white' }}>{seg.name}</div>
<div style={{ fontSize: 13 }}> <div style={{ fontSize: 13 }}>
Duration {prettyMs(durationMs)} {t('Duration')} {prettyMs(durationMs)}
</div> </div>
<div style={{ fontSize: 12 }}> <div style={{ fontSize: 12 }}>
({Math.floor(durationMs)} ms, {getFrameCount(duration)} frames) ({Math.floor(durationMs)} ms, {getFrameCount(duration)} frames)
@ -124,7 +127,7 @@ const SegmentList = memo(({
size={30} size={30}
style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: 'rgba(255, 255, 255, 0.2)' }} style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: 'rgba(255, 255, 255, 0.2)' }}
role="button" role="button"
title="Add segment" title={t('Add segment')}
onClick={addCutSegment} onClick={addCutSegment}
/> />
@ -132,13 +135,13 @@ const SegmentList = memo(({
size={30} size={30}
style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: cutSegments.length < 2 ? 'rgba(255, 255, 255, 0.2)' : currentSegActiveBgColor }} style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: cutSegments.length < 2 ? 'rgba(255, 255, 255, 0.2)' : currentSegActiveBgColor }}
role="button" role="button"
title={`Delete current segment ${currentSegIndex + 1}`} title={`${t('Delete current segment')} ${currentSegIndex + 1}`}
onClick={removeCutSegment} onClick={removeCutSegment}
/> />
<FaSortNumericDown <FaSortNumericDown
size={20} size={20}
title="Change segment order" title={t('Change segment order')}
role="button" role="button"
style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }} style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }}
onClick={onReorderSegsPress} onClick={onReorderSegsPress}
@ -146,7 +149,7 @@ const SegmentList = memo(({
<FaTag <FaTag
size={20} size={20}
title="Label segment" title={t('Label segment')}
role="button" role="button"
style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }} style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }}
onClick={onLabelSegmentPress} onClick={onLabelSegmentPress}
@ -154,7 +157,7 @@ const SegmentList = memo(({
</div> </div>
<div style={{ padding: 10, boxSizing: 'border-box', borderBottom: '1px solid grey', display: 'flex', justifyContent: 'space-between', fontSize: 13 }}> <div style={{ padding: 10, boxSizing: 'border-box', borderBottom: '1px solid grey', display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<div>Segments total:</div> <div>{t('Segments total:')}</div>
<div>{formatTimecode(cutSegments.reduce((acc, { start, end }) => (end - start) + acc, 0))}</div> <div>{formatTimecode(cutSegments.reduce((acc, { start, end }) => (end - start) + acc, 0))}</div>
</div> </div>
</Fragment> </Fragment>

View File

@ -1,41 +1,61 @@
import React, { Fragment, memo } from 'react'; import React, { Fragment, memo } from 'react';
import { Button, Table, SegmentedControl, Checkbox } from 'evergreen-ui'; import { Button, Table, SegmentedControl, Checkbox, Select } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
const Settings = memo(({ const Settings = memo(({
setOutputDir, customOutDir, autoMerge, setAutoMerge, keyframeCut, setKeyframeCut, invertCutSegments, setInvertCutSegments, setOutputDir, customOutDir, autoMerge, setAutoMerge, keyframeCut, setKeyframeCut, invertCutSegments, setInvertCutSegments,
autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose, autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose,
renderOutFmt, AutoExportToggler, renderCaptureFormatButton, onWheelTunerRequested, renderOutFmt, AutoExportToggler, renderCaptureFormatButton, onWheelTunerRequested, language, setLanguage,
}) => { }) => {
const { t } = useTranslation();
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
const Row = (props) => <Table.Row height="auto" paddingY={12} {...props} />; const Row = (props) => <Table.Row height="auto" paddingY={12} {...props} />;
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
const KeyCell = (props) => <Table.TextCell textProps={{ whiteSpace: 'auto' }} {...props} />; const KeyCell = (props) => <Table.TextCell textProps={{ whiteSpace: 'auto' }} {...props} />;
function onLangChange(e) {
const { value } = e.target;
const l = value !== '' ? value : undefined;
setLanguage(l);
}
return ( return (
<Fragment> <Fragment>
<Row> <Row>
<KeyCell textProps={{ whiteSpace: 'auto' }}>Output format (default autodetected)</KeyCell> <KeyCell>{t('App language')}</KeyCell>
<Table.TextCell>
<Select value={language || ''} onChange={onLangChange}>
<option key="" value="">{t('System language')}</option>
{['en'].map(lang => <option key={lang} value={lang}>{lang}</option>)}
</Select>
</Table.TextCell>
</Row>
<Row>
<KeyCell>{t('Output format (default autodetected)')}</KeyCell>
<Table.TextCell>{renderOutFmt({ width: '100%' })}</Table.TextCell> <Table.TextCell>{renderOutFmt({ width: '100%' })}</Table.TextCell>
</Row> </Row>
<Row> <Row>
<KeyCell> <KeyCell>
Working directory<br /> {t('Working directory')}<br />
This is where working files, exported files, project files (CSV) are stored. {t('This is where working files, exported files, project files (CSV) are stored.')}
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<Button onClick={setOutputDir}> <Button onClick={setOutputDir}>
{customOutDir ? 'Custom working directory' : 'Same directory as input file'} {customOutDir ? t('Custom working directory') : t('Same directory as input file')}
</Button> </Button>
<div>{customOutDir}</div> <div>{customOutDir}</div>
</Table.TextCell> </Table.TextCell>
</Row> </Row>
<Row> <Row>
<KeyCell>Auto merge segments to one file during export or export to separate files?</KeyCell> <KeyCell>{t('Auto merge segments to one file during export or export to separate files?')}</KeyCell>
<Table.TextCell> <Table.TextCell>
<SegmentedControl <SegmentedControl
options={[{ label: 'Auto merge', value: 'automerge' }, { label: 'Separate', value: 'separate' }]} options={[{ label: t('Auto merge'), value: 'automerge' }, { label: t('Separate'), value: 'separate' }]}
value={autoMerge ? 'automerge' : 'separate'} value={autoMerge ? 'automerge' : 'separate'}
onChange={value => setAutoMerge(value === 'automerge')} onChange={value => setAutoMerge(value === 'automerge')}
/> />
@ -44,13 +64,13 @@ const Settings = memo(({
<Row> <Row>
<KeyCell> <KeyCell>
Keyframe cut mode<br /> {t('Keyframe cut mode')}<br />
<b>Nearest keyframe</b>: Cut at the nearest keyframe (not accurate time.) Equiv to <i>ffmpeg -ss -i ...</i><br /> <b>{t('Keyframe cut')}</b>: Cut at the nearest keyframe (not accurate time.) Equiv to <i>ffmpeg -ss -i ...</i><br />
<b>Normal cut</b>: Accurate time but could leave an empty portion at the beginning of the video. Equiv to <i>ffmpeg -i -ss ...</i><br /> <b>{t('Normal cut')}</b>: Accurate time but could leave an empty portion at the beginning of the video. Equiv to <i>ffmpeg -i -ss ...</i><br />
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<SegmentedControl <SegmentedControl
options={[{ label: 'Nearest keyframe', value: 'keyframe' }, { label: 'Normal cut', value: 'normal' }]} options={[{ label: t('Keyframe cut'), value: 'keyframe' }, { label: t('Normal cut'), value: 'normal' }]}
value={keyframeCut ? 'keyframe' : 'normal'} value={keyframeCut ? 'keyframe' : 'normal'}
onChange={value => setKeyframeCut(value === 'keyframe')} onChange={value => setKeyframeCut(value === 'keyframe')}
/> />
@ -59,13 +79,13 @@ const Settings = memo(({
<Row> <Row>
<KeyCell> <KeyCell>
<span role="img" aria-label="Yin Yang"></span> Choose cutting mode: Remove or keep selected segments from video when exporting?<br /> <span role="img" aria-label="Yin Yang"></span> {t('Choose cutting mode: Remove or keep selected segments from video when exporting?')}<br />
When <b>Keep</b> is selected, the video inside segments will be kept, while the video outside will be discarded.<br /> <b>{t('Keep')}</b>: {t('The video inside segments will be kept, while the video outside will be discarded.')}<br />
When <b>Remove</b> is selected, the video inside segments will be discarded, while the video surrounding them will be kept. <b>{t('Remove')}</b>: {t('The video inside segments will be discarded, while the video surrounding them will be kept.')}
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<SegmentedControl <SegmentedControl
options={[{ label: 'Remove', value: 'discard' }, { label: 'Keep', value: 'keep' }]} options={[{ label: t('Remove'), value: 'discard' }, { label: t('Keep'), value: 'keep' }]}
value={invertCutSegments ? 'discard' : 'keep'} value={invertCutSegments ? 'discard' : 'keep'}
onChange={value => setInvertCutSegments(value === 'discard')} onChange={value => setInvertCutSegments(value === 'discard')}
/> />
@ -74,8 +94,8 @@ const Settings = memo(({
<Row> <Row>
<KeyCell> <KeyCell>
Extract unprocessable tracks to separate files or discard them?<br /> {t('Extract unprocessable tracks to separate files or discard them?')}<br />
(data tracks such as GoPro GPS, telemetry etc. are not copied over by default because ffmpeg cannot cut them, thus they will cause the media duration to stay the same after cutting video/audio) {t('(data tracks such as GoPro GPS, telemetry etc. are not copied over by default because ffmpeg cannot cut them, thus they will cause the media duration to stay the same after cutting video/audio)')}
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<AutoExportToggler /> <AutoExportToggler />
@ -84,12 +104,12 @@ const Settings = memo(({
<Row> <Row>
<KeyCell> <KeyCell>
Auto save project file?<br /> {t('Auto save project file?')}<br />
The project will be stored along with the output files as a CSV file {t('The project will be stored along with the output files as a CSV file')}
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<Checkbox <Checkbox
label="Auto save project" label={t('Auto save project')}
checked={autoSaveProjectFile} checked={autoSaveProjectFile}
onChange={e => setAutoSaveProjectFile(e.target.checked)} onChange={e => setAutoSaveProjectFile(e.target.checked)}
/> />
@ -98,7 +118,7 @@ const Settings = memo(({
<Row> <Row>
<KeyCell> <KeyCell>
Snapshot capture format {t('Snapshot capture format')}
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
{renderCaptureFormatButton()} {renderCaptureFormatButton()}
@ -106,10 +126,10 @@ const Settings = memo(({
</Row> </Row>
<Row> <Row>
<KeyCell>In timecode show</KeyCell> <KeyCell>{t('In timecode show')}</KeyCell>
<Table.TextCell> <Table.TextCell>
<SegmentedControl <SegmentedControl
options={[{ label: 'Frame numbers', value: 'frames' }, { label: 'Millisecond fractions', value: 'ms' }]} options={[{ label: t('Frame numbers'), value: 'frames' }, { label: t('Millisecond fractions'), value: 'ms' }]}
value={timecodeShowFrames ? 'frames' : 'ms'} value={timecodeShowFrames ? 'frames' : 'ms'}
onChange={value => setTimecodeShowFrames(value === 'frames')} onChange={value => setTimecodeShowFrames(value === 'frames')}
/> />
@ -117,17 +137,17 @@ const Settings = memo(({
</Row> </Row>
<Row> <Row>
<KeyCell>Scroll/wheel sensitivity</KeyCell> <KeyCell>{t('Scroll/wheel sensitivity')}</KeyCell>
<Table.TextCell> <Table.TextCell>
<Button onClick={onWheelTunerRequested}>Change sensitivity</Button> <Button onClick={onWheelTunerRequested}>{t('Change sensitivity')}</Button>
</Table.TextCell> </Table.TextCell>
</Row> </Row>
<Row> <Row>
<KeyCell>Ask for confirmation when closing app or file?</KeyCell> <KeyCell>{t('Ask for confirmation when closing app or file?')}</KeyCell>
<Table.TextCell> <Table.TextCell>
<Checkbox <Checkbox
label="Ask before closing" label={t('Ask before closing')}
checked={askBeforeClose} checked={askBeforeClose}
onChange={e => setAskBeforeClose(e.target.checked)} onChange={e => setAskBeforeClose(e.target.checked)}
/> />

View File

@ -2,36 +2,41 @@ import React, { memo } from 'react';
import { IoIosCloseCircleOutline } from 'react-icons/io'; import { IoIosCloseCircleOutline } from 'react-icons/io';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Table } from 'evergreen-ui'; import { Table } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
const SettingsSheet = memo(({ const SettingsSheet = memo(({
visible, onTogglePress, renderSettings, visible, onTogglePress, renderSettings,
}) => ( }) => {
<AnimatePresence> const { t } = useTranslation();
{visible && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="help-sheet"
>
<IoIosCloseCircleOutline role="button" onClick={onTogglePress} size={30} style={{ position: 'fixed', right: 0, top: 0, padding: 20 }} />
<Table style={{ marginTop: 40 }}> return (
<Table.Head> <AnimatePresence>
<Table.TextHeaderCell> {visible && (
Settings <motion.div
</Table.TextHeaderCell> initial={{ scale: 0, opacity: 0 }}
<Table.TextHeaderCell> animate={{ scale: 1, opacity: 1 }}
Current setting exit={{ scale: 0, opacity: 0 }}
</Table.TextHeaderCell> className="help-sheet"
</Table.Head> >
<Table.Body> <IoIosCloseCircleOutline role="button" onClick={onTogglePress} size={30} style={{ position: 'fixed', right: 0, top: 0, padding: 20 }} />
{renderSettings()}
</Table.Body> <Table style={{ marginTop: 40 }}>
</Table> <Table.Head>
</motion.div> <Table.TextHeaderCell>
)} {t('Settings')}
</AnimatePresence> </Table.TextHeaderCell>
)); <Table.TextHeaderCell>
{t('Current setting')}
</Table.TextHeaderCell>
</Table.Head>
<Table.Body>
{renderSettings()}
</Table.Body>
</Table>
</motion.div>
)}
</AnimatePresence>
);
});
export default SettingsSheet; export default SettingsSheet;

View File

@ -6,6 +6,7 @@ import { MdSubtitles } from 'react-icons/md';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import { SegmentedControl } from 'evergreen-ui'; import { SegmentedControl } from 'evergreen-ui';
import withReactContent from 'sweetalert2-react-content'; import withReactContent from 'sweetalert2-react-content';
import { useTranslation } from 'react-i18next';
import { formatDuration } from './util'; import { formatDuration } from './util';
import { getStreamFps } from './ffmpeg'; import { getStreamFps } from './ffmpeg';
@ -22,6 +23,8 @@ function onInfoClick(s, title) {
} }
const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => { const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
const { t } = useTranslation();
const bitrate = parseInt(stream.bit_rate, 10); const bitrate = parseInt(stream.bit_rate, 10);
const streamDuration = parseInt(stream.duration, 10); const streamDuration = parseInt(stream.duration, 10);
const duration = !Number.isNaN(streamDuration) ? streamDuration : fileDuration; const duration = !Number.isNaN(streamDuration) ? streamDuration : fileDuration;
@ -52,20 +55,22 @@ const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
<td>{stream.nb_frames}</td> <td>{stream.nb_frames}</td>
<td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</td> <td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</td>
<td>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`}</td> <td>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`}</td>
<td><FaInfoCircle role="button" onClick={() => onInfoClick(stream, 'Stream info')} size={26} /></td> <td><FaInfoCircle role="button" onClick={() => onInfoClick(stream, t('Stream info'))} size={26} /></td>
</tr> </tr>
); );
}); });
function renderFileRow(path, formatData, onTrashClick) { const FileRow = ({ path, formatData, onTrashClick }) => {
const { t } = useTranslation();
return ( return (
<tr> <tr>
<td>{onTrashClick && <FaTrashAlt size={20} role="button" style={{ padding: '0 5px', cursor: 'pointer' }} onClick={onTrashClick} />}</td> <td>{onTrashClick && <FaTrashAlt size={20} role="button" style={{ padding: '0 5px', cursor: 'pointer' }} onClick={onTrashClick} />}</td>
<td colSpan={8} title={path} style={{ wordBreak: 'break-all', fontWeight: 'bold' }}>{path.replace(/.*\/([^/]+)$/, '$1')}</td> <td colSpan={8} title={path} style={{ wordBreak: 'break-all', fontWeight: 'bold' }}>{path.replace(/.*\/([^/]+)$/, '$1')}</td>
<td><FaInfoCircle role="button" onClick={() => onInfoClick(formatData, 'File info')} size={26} /></td> <td><FaInfoCircle role="button" onClick={() => onInfoClick(formatData, t('File info'))} size={26} /></td>
</tr> </tr>
); );
} };
const StreamsSelector = memo(({ const StreamsSelector = memo(({
mainFilePath, mainFileFormatData, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId, mainFilePath, mainFileFormatData, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId,
@ -73,6 +78,8 @@ const StreamsSelector = memo(({
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, areWeCutting, showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, areWeCutting,
AutoExportToggler, AutoExportToggler,
}) => { }) => {
const { t } = useTranslation();
if (!existingStreams) return null; if (!existingStreams) return null;
function getFormatDuration(formatData) { function getFormatDuration(formatData) {
@ -94,26 +101,26 @@ const StreamsSelector = memo(({
return ( return (
<div style={{ color: 'black', padding: 10 }}> <div style={{ color: 'black', padding: 10 }}>
<p>Click to select which tracks to keep when exporting:</p> <p>{t('Click to select which tracks to keep when exporting:')}</p>
<table style={{ marginBottom: 10 }}> <table style={{ marginBottom: 10 }}>
<thead style={{ background: 'rgba(0,0,0,0.1)' }}> <thead style={{ background: 'rgba(0,0,0,0.1)' }}>
<tr> <tr>
<th>Keep?</th> <th>{t('Keep?')}</th>
<th /> <th />
<th>Type</th> <th>{t('Type')}</th>
<th>Tag</th> <th>{t('Tag')}</th>
<th>Codec</th> <th>{t('Codec')}</th>
<th>Duration</th> <th>{t('Duration')}</th>
<th>Frames</th> <th>{t('Frames')}</th>
<th>Bitrate</th> <th>{t('Bitrate')}</th>
<th>Data</th> <th>{t('Data')}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{renderFileRow(mainFilePath, mainFileFormatData)} <FileRow path={mainFilePath} formatData={mainFileFormatData} />
{existingStreams.map((stream) => ( {existingStreams.map((stream) => (
<Stream <Stream
@ -129,7 +136,7 @@ const StreamsSelector = memo(({
<Fragment key={path}> <Fragment key={path}>
<tr><td colSpan={10} /></tr> <tr><td colSpan={10} /></tr>
{renderFileRow(path, formatData, () => removeFile(path))} <FileRow path={path} formatData={formatData} onTrashClick={() => removeFile(path)} />
{streams.map((stream) => ( {streams.map((stream) => (
<Stream <Stream
@ -148,10 +155,10 @@ const StreamsSelector = memo(({
{externalFilesEntries.length > 0 && !areWeCutting && ( {externalFilesEntries.length > 0 && !areWeCutting && (
<div style={{ margin: '10px 0' }}> <div style={{ margin: '10px 0' }}>
<div> <div>
If the streams have different length, do you want to make the combined output file as long as the longest stream or the shortest stream? {t('If the streams have different length, do you want to make the combined output file as long as the longest stream or the shortest stream?')}
</div> </div>
<SegmentedControl <SegmentedControl
options={[{ label: 'Longest', value: 'longest' }, { label: 'Shortest', value: 'shortest' }]} options={[{ label: t('Longest'), value: 'longest' }, { label: t('Shortest'), value: 'shortest' }]}
value={shortestFlag ? 'shortest' : 'longest'} value={shortestFlag ? 'shortest' : 'longest'}
onChange={value => setShortestFlag(value === 'shortest')} onChange={value => setShortestFlag(value === 'shortest')}
/> />
@ -161,18 +168,18 @@ const StreamsSelector = memo(({
{nonCopiedExtraStreams.length > 0 && ( {nonCopiedExtraStreams.length > 0 && (
<div style={{ margin: '10px 0' }}> <div style={{ margin: '10px 0' }}>
Discard or extract unprocessable tracks to separate files? {t('Discard or extract unprocessable tracks to separate files?')}
<AutoExportToggler /> <AutoExportToggler />
</div> </div>
)} )}
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}> <div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={showAddStreamSourceDialog}>
<FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Include more tracks from other file <FaFileImport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> {t('Include more tracks from other file')}
</div> </div>
{externalFilesEntries.length === 0 && ( {externalFilesEntries.length === 0 && (
<div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={onExtractAllStreamsPress}> <div style={{ cursor: 'pointer', padding: '10px 0' }} role="button" onClick={onExtractAllStreamsPress}>
<FaFileExport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> Export each track as individual files <FaFileExport size={30} style={{ verticalAlign: 'middle', marginRight: 5 }} /> {t('Export each track as individual files')}
</div> </div>
)} )}
</div> </div>

View File

@ -2,6 +2,7 @@ import React, { memo, useRef, useMemo, useCallback, useEffect, useState } from '
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Hammer from 'react-hammerjs'; import Hammer from 'react-hammerjs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import TimelineSeg from './TimelineSeg'; import TimelineSeg from './TimelineSeg';
import InverseCutSegment from './InverseCutSegment'; import InverseCutSegment from './InverseCutSegment';
@ -44,6 +45,8 @@ const Timeline = memo(({
waveform, shouldShowWaveform, shouldShowKeyframes, timelineHeight, thumbnails, waveform, shouldShowWaveform, shouldShowKeyframes, timelineHeight, thumbnails,
onZoomWindowStartTimeChange, waveformEnabled, thumbnailsEnabled, wheelSensitivity, onZoomWindowStartTimeChange, waveformEnabled, thumbnailsEnabled, wheelSensitivity,
}) => { }) => {
const { t } = useTranslation();
const timelineScrollerRef = useRef(); const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef(); const timelineScrollerSkipEventRef = useRef();
const timelineScrollerSkipEventDebounce = useRef(); const timelineScrollerSkipEventDebounce = useRef();
@ -241,7 +244,7 @@ const Timeline = memo(({
{(waveformEnabled && !thumbnailsEnabled && !shouldShowWaveform) && ( {(waveformEnabled && !thumbnailsEnabled && !shouldShowWaveform) && (
<div style={{ position: 'absolute', display: 'flex', alignItems: 'center', justifyContent: 'center', height: timelineHeight, bottom: timelineHeight, left: 0, right: 0, color: 'rgba(255,255,255,0.6)' }}> <div style={{ position: 'absolute', display: 'flex', alignItems: 'center', justifyContent: 'center', height: timelineHeight, bottom: timelineHeight, left: 0, right: 0, color: 'rgba(255,255,255,0.6)' }}>
Zoom in more to view waveform {t('Zoom in more to view waveform')}
</div> </div>
)} )}

View File

@ -2,6 +2,7 @@ import React, { Fragment, memo } from 'react';
import { FaHandPointLeft, FaHandPointRight, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey } from 'react-icons/fa'; import { FaHandPointLeft, FaHandPointRight, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey } from 'react-icons/fa';
import { GiSoundWaves } from 'react-icons/gi'; import { GiSoundWaves } from 'react-icons/gi';
import { IoMdKey } from 'react-icons/io'; import { IoMdKey } from 'react-icons/io';
import { useTranslation } from 'react-i18next';
// import useTraceUpdate from 'use-trace-update'; // import useTraceUpdate from 'use-trace-update';
import { getSegColors, parseDuration, formatDuration } from './util'; import { getSegColors, parseDuration, formatDuration } from './util';
@ -15,6 +16,8 @@ const TimelineControls = memo(({
playing, shortStep, playCommand, setTimelineMode, hasAudio, hasVideo, timelineMode, playing, shortStep, playCommand, setTimelineMode, hasAudio, hasVideo, timelineMode,
keyframesEnabled, setKeyframesEnabled, seekClosestKeyframe, keyframesEnabled, setKeyframesEnabled, seekClosestKeyframe,
}) => { }) => {
const { t } = useTranslation();
const { const {
segActiveBgColor: currentSegActiveBgColor, segActiveBgColor: currentSegActiveBgColor,
segBorderColor: currentSegBorderColor, segBorderColor: currentSegBorderColor,
@ -53,7 +56,7 @@ const TimelineControls = memo(({
<div <div
style={{ ...segButtonStyle, height: 10, padding: 4, margin: '0 5px' }} style={{ ...segButtonStyle, height: 10, padding: 4, margin: '0 5px' }}
role="button" role="button"
title={`Select ${direction > 0 ? 'next' : 'previous'} segment (${newIndex + 1})`} title={`${direction > 0 ? t('Select next segment') : t('Select previous segment')} (${newIndex + 1})`}
onClick={() => seg && setCurrentSegIndex(newIndex)} onClick={() => seg && setCurrentSegIndex(newIndex)}
> >
{newIndex + 1} {newIndex + 1}
@ -102,7 +105,7 @@ const TimelineControls = memo(({
<input <input
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }} style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
type="text" type="text"
title={`Manually input cut ${isStart ? 'start' : 'end'} point`} title={isStart ? t('Manually input cut start point') : t('Manually input cut end point')}
onChange={e => handleCutTimeInput(e.target.value)} onChange={e => handleCutTimeInput(e.target.value)}
value={isCutTimeManualSet() value={isCutTimeManualSet()
? cutTimeManual ? cutTimeManual
@ -123,7 +126,7 @@ const TimelineControls = memo(({
size={24} size={24}
style={{ padding: '0 5px', color: timelineMode === 'waveform' ? primaryTextColor : undefined }} style={{ padding: '0 5px', color: timelineMode === 'waveform' ? primaryTextColor : undefined }}
role="button" role="button"
title="Show waveform" title={t('Show waveform')}
onClick={() => setTimelineMode('waveform')} onClick={() => setTimelineMode('waveform')}
/> />
)} )}
@ -133,7 +136,7 @@ const TimelineControls = memo(({
size={20} size={20}
style={{ padding: '0 5px', color: timelineMode === 'thumbnails' ? primaryTextColor : undefined }} style={{ padding: '0 5px', color: timelineMode === 'thumbnails' ? primaryTextColor : undefined }}
role="button" role="button"
title="Show thumbnails" title={t('Show thumbnails')}
onClick={() => setTimelineMode('thumbnails')} onClick={() => setTimelineMode('thumbnails')}
/> />
@ -141,7 +144,7 @@ const TimelineControls = memo(({
size={16} size={16}
style={{ padding: '0 5px', color: keyframesEnabled ? primaryTextColor : undefined }} style={{ padding: '0 5px', color: keyframesEnabled ? primaryTextColor : undefined }}
role="button" role="button"
title="Show keyframes" title={t('Show keyframes')}
onClick={() => setKeyframesEnabled(v => !v)} onClick={() => setKeyframesEnabled(v => !v)}
/> />
</Fragment> </Fragment>
@ -152,30 +155,30 @@ const TimelineControls = memo(({
<FaStepBackward <FaStepBackward
size={16} size={16}
title="Jump to start of video" title={t('Jump to start of video')}
role="button" role="button"
onClick={() => seekAbs(0)} onClick={() => seekAbs(0)}
/> />
{renderJumpCutpointButton(-1)} {renderJumpCutpointButton(-1)}
{renderSetCutpointButton({ side: 'start', Icon: FaStepBackward, onClick: jumpCutStart, title: 'Jump to cut start', style: { marginRight: 5 } })} {renderSetCutpointButton({ side: 'start', Icon: FaStepBackward, onClick: jumpCutStart, title: t('Jump to cut start'), style: { marginRight: 5 } })}
{renderSetCutpointButton({ side: 'start', Icon: FaHandPointLeft, onClick: setCutStart, title: 'Set cut start to current position' })} {renderSetCutpointButton({ side: 'start', Icon: FaHandPointLeft, onClick: setCutStart, title: t('Set cut start to current position') })}
{renderCutTimeInput('start')} {renderCutTimeInput('start')}
<IoMdKey <IoMdKey
size={20} size={20}
role="button" role="button"
title="Seek previous keyframe" title={t('Seek previous keyframe')}
style={{ transform: 'matrix(-1, 0, 0, 1, 0, 0)' }} style={{ transform: 'matrix(-1, 0, 0, 1, 0, 0)' }}
onClick={() => seekClosestKeyframe(-1)} onClick={() => seekClosestKeyframe(-1)}
/> />
<FaCaretLeft <FaCaretLeft
size={20} size={20}
role="button" role="button"
title="One frame back" title={t('One frame back')}
onClick={() => shortStep(-1)} onClick={() => shortStep(-1)}
/> />
<PlayPause <PlayPause
@ -186,27 +189,27 @@ const TimelineControls = memo(({
<FaCaretRight <FaCaretRight
size={20} size={20}
role="button" role="button"
title="One frame forward" title={t('One frame forward')}
onClick={() => shortStep(1)} onClick={() => shortStep(1)}
/> />
<IoMdKey <IoMdKey
size={20} size={20}
role="button" role="button"
title="Seek next keyframe" title={t('Seek next keyframe')}
onClick={() => seekClosestKeyframe(1)} onClick={() => seekClosestKeyframe(1)}
/> />
{renderCutTimeInput('end')} {renderCutTimeInput('end')}
{renderSetCutpointButton({ side: 'end', Icon: FaHandPointRight, onClick: setCutEnd, title: 'Set cut end to current position' })} {renderSetCutpointButton({ side: 'end', Icon: FaHandPointRight, onClick: setCutEnd, title: t('Set cut end to current position') })}
{renderSetCutpointButton({ side: 'end', Icon: FaStepForward, onClick: jumpCutEnd, title: 'Jump to cut end', style: { marginLeft: 5 } })} {renderSetCutpointButton({ side: 'end', Icon: FaStepForward, onClick: jumpCutEnd, title: t('Jump to cut end'), style: { marginLeft: 5 } })}
{renderJumpCutpointButton(1)} {renderJumpCutpointButton(1)}
<FaStepForward <FaStepForward
size={16} size={16}
title="Jump to end of video" title={t('Jump to end of video')}
role="button" role="button"
onClick={() => seekAbs(duration)} onClick={() => seekAbs(duration)}
/> />

View File

@ -2,6 +2,7 @@ import React, { Fragment, memo } from 'react';
import { IoIosHelpCircle, IoIosSettings } from 'react-icons/io'; import { IoIosHelpCircle, IoIosSettings } from 'react-icons/io';
import { Button } from 'evergreen-ui'; import { Button } from 'evergreen-ui';
import { MdCallSplit, MdCallMerge } from 'react-icons/md'; import { MdCallSplit, MdCallMerge } from 'react-icons/md';
import { useTranslation } from 'react-i18next';
import { withBlur } from './util'; import { withBlur } from './util';
@ -11,6 +12,8 @@ const TopMenu = memo(({
renderOutFmt, outSegments, autoMerge, toggleAutoMerge, keyframeCut, toggleKeyframeCut, toggleHelp, renderOutFmt, outSegments, autoMerge, toggleAutoMerge, keyframeCut, toggleKeyframeCut, toggleHelp,
numStreamsToCopy, numStreamsTotal, setStreamsSelectorShown, toggleSettings, numStreamsToCopy, numStreamsTotal, setStreamsSelectorShown, toggleSettings,
}) => { }) => {
const { t } = useTranslation();
const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit; const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit;
return ( return (
@ -18,16 +21,16 @@ const TopMenu = memo(({
{filePath && ( {filePath && (
<Fragment> <Fragment>
<Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}> <Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}>
Tracks ({numStreamsToCopy}/{numStreamsTotal}) {t('Tracks')} ({numStreamsToCopy}/{numStreamsTotal})
</Button> </Button>
<Button <Button
iconBefore={copyAnyAudioTrack ? 'volume-up' : 'volume-off'} iconBefore={copyAnyAudioTrack ? 'volume-up' : 'volume-off'}
height={20} height={20}
title={`Discard audio? Current: ${copyAnyAudioTrack ? 'keep audio tracks' : 'Discard audio tracks'}`} title={`${t('Discard audio? Current:')} ${copyAnyAudioTrack ? t('Keep audio tracks') : t('Discard audio tracks')}`}
onClick={withBlur(toggleStripAudio)} onClick={withBlur(toggleStripAudio)}
> >
{copyAnyAudioTrack ? 'Keep audio' : 'Discard audio'} {copyAnyAudioTrack ? t('Keep audio') : t('Discard audio')}
</Button> </Button>
</Fragment> </Fragment>
)} )}
@ -42,7 +45,7 @@ const TopMenu = memo(({
onClick={withBlur(setOutputDir)} onClick={withBlur(setOutputDir)}
title={customOutDir} title={customOutDir}
> >
{`Working dir ${customOutDir ? 'set' : 'unset'}`} {customOutDir ? t('Working dir set') : t('Working dir unset')}
</Button> </Button>
<div style={{ width: 60 }}>{renderOutFmt({ height: 20 })}</div> <div style={{ width: 60 }}>{renderOutFmt({ height: 20 })}</div>
@ -50,19 +53,19 @@ const TopMenu = memo(({
<Button <Button
height={20} height={20}
style={{ opacity: outSegments && outSegments.length < 2 ? 0.4 : undefined }} style={{ opacity: outSegments && outSegments.length < 2 ? 0.4 : undefined }}
title={autoMerge ? 'Auto merge segments to one file after export' : 'Export to separate files'} title={autoMerge ? t('Auto merge segments to one file after export') : t('Export to separate files')}
onClick={withBlur(toggleAutoMerge)} onClick={withBlur(toggleAutoMerge)}
> >
<AutoMergeIcon /> {autoMerge ? 'Merge cuts' : 'Separate files'} <AutoMergeIcon /> {autoMerge ? t('Merge cuts') : t('Separate files')}
</Button> </Button>
<Button <Button
height={20} height={20}
iconBefore={keyframeCut ? 'key' : undefined} iconBefore={keyframeCut ? 'key' : undefined}
title={`Cut mode is ${keyframeCut ? 'keyframe cut' : 'normal cut'}`} title={`${t('Cut mode is:')} ${keyframeCut ? t('Keyframe cut') : t('Normal cut')}`}
onClick={withBlur(toggleKeyframeCut)} onClick={withBlur(toggleKeyframeCut)}
> >
{keyframeCut ? 'Keyframe cut' : 'Normal cut'} {keyframeCut ? t('Keyframe cut') : t('Normal cut')}
</Button> </Button>
</Fragment> </Fragment>
)} )}

View File

@ -1,5 +1,6 @@
import parse from 'csv-parse'; import parse from 'csv-parse';
import stringify from 'csv-stringify'; import stringify from 'csv-stringify';
import i18n from 'i18next';
const fs = window.require('fs-extra'); const fs = window.require('fs-extra');
const { promisify } = window.require('util'); const { promisify } = window.require('util');
@ -10,8 +11,8 @@ const parseAsync = promisify(parse);
export async function load(path) { export async function load(path) {
const str = await fs.readFile(path, 'utf-8'); const str = await fs.readFile(path, 'utf-8');
const rows = await parseAsync(str, {}); const rows = await parseAsync(str, {});
if (rows.length === 0) throw new Error('No rows found'); if (rows.length === 0) throw new Error(i18n.t('No rows found'));
if (!rows.every(row => row.length === 3)) throw new Error('One or more rows does not have 3 columns'); if (!rows.every(row => row.length === 3)) throw new Error(i18n.t('One or more rows does not have 3 columns'));
const mapped = rows const mapped = rows
.map(([start, end, name]) => ({ .map(([start, end, name]) => ({
@ -25,7 +26,7 @@ export async function load(path) {
&& (end === undefined || !Number.isNaN(end)) && (end === undefined || !Number.isNaN(end))
))) { ))) {
console.log(mapped); console.log(mapped);
throw new Error('Invalid start or end value. Must contain a number of seconds'); throw new Error(i18n.t('Invalid start or end value. Must contain a number of seconds'));
} }
return mapped; return mapped;

View File

@ -4,6 +4,7 @@ import flatMapDeep from 'lodash/flatMapDeep';
import sum from 'lodash/sum'; import sum from 'lodash/sum';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import moment from 'moment'; import moment from 'moment';
import i18n from 'i18next';
import { formatDuration, getOutPath, transferTimestamps, filenamify } from './util'; import { formatDuration, getOutPath, transferTimestamps, filenamify } from './util';
@ -43,7 +44,7 @@ function getFfprobePath() {
const subPath = map[platform]; const subPath = map[platform];
if (!subPath) throw new Error(`Unsupported platform ${platform}`); if (!subPath) throw new Error(`${i18n.t('Unsupported platform')} ${platform}`);
return isDev return isDev
? `node_modules/ffprobe-static/bin/${subPath}` ? `node_modules/ffprobe-static/bin/${subPath}`
@ -127,12 +128,12 @@ export function getSafeCutTime(frames, cutTime, nextMode) {
let index; let index;
if (frames.length < 2) throw new Error('Less than 2 frames found'); if (frames.length < 2) throw new Error(i18n.t('Less than 2 frames found'));
if (nextMode) { if (nextMode) {
index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma); index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma);
if (index === -1) throw new Error('Failed to find next keyframe'); if (index === -1) throw new Error(i18n.t('Failed to find next keyframe'));
if (index >= frames.length - 1) throw new Error('We are on the last frame'); if (index >= frames.length - 1) throw new Error(i18n.t('We are on the last frame'));
const { time } = frames[index]; const { time } = frames[index];
if (isCloseTo(time, cutTime)) { if (isCloseTo(time, cutTime)) {
return undefined; // Already on keyframe, no need to modify cut time return undefined; // Already on keyframe, no need to modify cut time
@ -147,8 +148,8 @@ export function getSafeCutTime(frames, cutTime, nextMode) {
}; };
index = findReverseIndex(frames, f => f.time <= cutTime + sigma); index = findReverseIndex(frames, f => f.time <= cutTime + sigma);
if (index === -1) throw new Error('Failed to find any prev frame'); if (index === -1) throw new Error(i18n.t('Failed to find any prev frame'));
if (index === 0) throw new Error('We are on the first frame'); if (index === 0) throw new Error(i18n.t('We are on the first frame'));
if (index === frames.length - 1) { if (index === frames.length - 1) {
// Last frame of video, no need to modify cut time // Last frame of video, no need to modify cut time
@ -161,8 +162,8 @@ export function getSafeCutTime(frames, cutTime, nextMode) {
// We are not on a frame before keyframe, look for preceding keyframe instead // We are not on a frame before keyframe, look for preceding keyframe instead
index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma); index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma);
if (index === -1) throw new Error('Failed to find any prev keyframe'); if (index === -1) throw new Error(i18n.t('Failed to find any prev keyframe'));
if (index === 0) throw new Error('We are on the first keyframe'); if (index === 0) throw new Error(i18n.t('We are on the first keyframe'));
// Use frame before the found keyframe // Use frame before the found keyframe
return frames[index - 1].time; return frames[index - 1].time;

47
src/i18n.js Normal file
View File

@ -0,0 +1,47 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const LanguageDetector = window.require('i18next-electron-language-detector');
const Backend = window.require('i18next-node-fs-backend');
const isDev = window.require('electron-is-dev');
const { app } = window.require('electron').remote;
const { join } = require('path');
const getLangPath = (subPath) => (isDev ? join('public', subPath) : join(app.getAppPath(), 'build', subPath));
// https://github.com/i18next/i18next/issues/869
i18n
.use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
fallbackLng: 'en',
debug: isDev,
// saveMissing: isDev,
// updateMissing: isDev,
// saveMissingTo: 'all',
// TODO improve keys?
keySeparator: false,
nsSeparator: false,
pluralSeparator: false,
contextSeparator: false,
backend: {
loadPath: getLangPath('locales/{{lng}}/{{ns}}.json'),
addPath: getLangPath('locales/{{lng}}/{{ns}}.missing.json'),
},
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
});
export default i18n;

View File

@ -1,11 +1,12 @@
import React from 'react'; import React, { Suspense } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import './main.css';
import App from './App'; import App from './App';
import './i18n';
import './main.css';
const electron = window.require('electron'); const electron = window.require('electron');
console.log('Version', electron.remote.app.getVersion()); console.log('Version', electron.remote.app.getVersion());
ReactDOM.render(<App />, document.getElementById('root')); ReactDOM.render(<Suspense fallback={<div />}><App /></Suspense>, document.getElementById('root'));

View File

@ -6,6 +6,7 @@ import {
} from 'react-sortable-hoc'; } from 'react-sortable-hoc';
import { basename } from 'path'; import { basename } from 'path';
import { Checkbox } from 'evergreen-ui'; import { Checkbox } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
const rowStyle = { const rowStyle = {
padding: 5, fontSize: 14, margin: '7px 0', boxShadow: '0 0 5px 0px rgba(0,0,0,0.3)', overflowY: 'auto', whiteSpace: 'nowrap', padding: 5, fontSize: 14, margin: '7px 0', boxShadow: '0 0 5px 0px rgba(0,0,0,0.3)', overflowY: 'auto', whiteSpace: 'nowrap',
@ -41,9 +42,12 @@ const SortableFiles = memo(({
setItems(newItems); setItems(newItems);
}, [items]); }, [items]);
const { t } = useTranslation();
return ( return (
<div> <div>
<div><b>Sort your files for merge</b></div> <div><b>{t('Sort your files for merge')}</b></div>
<SortableContainer <SortableContainer
items={items} items={items}
onSortEnd={onSortEnd} onSortEnd={onSortEnd}
@ -53,7 +57,7 @@ const SortableFiles = memo(({
/> />
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<Checkbox checked={allStreams} onChange={e => setAllStreams(e.target.checked)} label="Include all streams?" /> <Checkbox checked={allStreams} onChange={e => setAllStreams(e.target.checked)} label={t('Include all streams?')} />
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import swal from 'sweetalert2'; import swal from 'sweetalert2';
import i18n from 'i18next';
import withReactContent from 'sweetalert2-react-content'; import withReactContent from 'sweetalert2-react-content';
import SortableFiles from './SortableFiles'; import SortableFiles from './SortableFiles';
@ -13,7 +13,7 @@ const MySwal = withReactContent(swal);
export async function showMergeDialog(paths, onMergeClick) { export async function showMergeDialog(paths, onMergeClick) {
if (!paths) return; if (!paths) return;
if (paths.length < 2) { if (paths.length < 2) {
errorToast('More than one file must be selected'); errorToast(i18n.t('More than one file must be selected'));
return; return;
} }
@ -23,7 +23,7 @@ export async function showMergeDialog(paths, onMergeClick) {
const { dismiss } = await MySwal.fire({ const { dismiss } = await MySwal.fire({
width: '90%', width: '90%',
showCancelButton: true, showCancelButton: true,
confirmButtonText: 'Merge!', confirmButtonText: i18n.t('Merge!'),
onBeforeOpen: (el) => { swalElem = el; }, onBeforeOpen: (el) => { swalElem = el; },
html: (<SortableFiles html: (<SortableFiles
items={outPaths} items={outPaths}
@ -39,8 +39,8 @@ export async function showMergeDialog(paths, onMergeClick) {
} }
export async function showOpenAndMergeDialog({ dialog, defaultPath, onMergeClick }) { export async function showOpenAndMergeDialog({ dialog, defaultPath, onMergeClick }) {
const title = 'Please select files to be merged'; const title = i18n.t('Please select files to be merged');
const message = 'Please select files to be merged. The files need to be of the exact same format and codecs'; const message = i18n.t('Please select files to be merged. The files need to be of the exact same format and codecs');
const { canceled, filePaths } = await dialog.showOpenDialog({ const { canceled, filePaths } = await dialog.showOpenDialog({
title, title,
defaultPath, defaultPath,

View File

@ -13,5 +13,6 @@ export default new Store({
muted: false, muted: false,
autoSaveProjectFile: true, autoSaveProjectFile: true,
wheelSensitivity: 0.2, wheelSensitivity: 0.2,
language: undefined,
}, },
}); });

View File

@ -1,5 +1,6 @@
import padStart from 'lodash/padStart'; import padStart from 'lodash/padStart';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import i18n from 'i18next';
import randomColor from './random-color'; import randomColor from './random-color';
@ -84,7 +85,7 @@ export const errorToast = (title) => toast.fire({
export async function showFfmpegFail(err) { export async function showFfmpegFail(err) {
console.error(err); console.error(err);
return errorToast(`Failed to run ffmpeg: ${err.stack}`); return errorToast(`${i18n.t('Failed to run ffmpeg:')} ${err.stack}`);
} }
export function setFileNameTitle(filePath) { export function setFileNameTitle(filePath) {
@ -98,8 +99,8 @@ export function filenamify(name) {
export async function promptTimeOffset(inputValue) { export async function promptTimeOffset(inputValue) {
const { value } = await Swal.fire({ const { value } = await Swal.fire({
title: 'Set custom start time offset', title: i18n.t('Set custom start time offset'),
text: 'Instead of video apparently starting at 0, you can offset by a specified value (useful for viewing/cutting videos according to timecodes)', text: i18n.t('Instead of video apparently starting at 0, you can offset by a specified value (useful for viewing/cutting videos according to timecodes)'),
input: 'text', input: 'text',
inputValue: inputValue || '', inputValue: inputValue || '',
showCancelButton: true, showCancelButton: true,

View File

@ -1014,6 +1014,13 @@
dependencies: dependencies:
regenerator-runtime "^0.12.0" regenerator-runtime "^0.12.0"
"@babel/runtime@^7.3.1":
version "7.8.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d"
integrity sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.1.0": "@babel/template@^7.1.0":
version "7.1.2" version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644"
@ -6174,6 +6181,13 @@ html-minifier-terser@^5.0.1:
relateurl "^0.2.7" relateurl "^0.2.7"
terser "^4.6.3" terser "^4.6.3"
html-parse-stringify2@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz#dc5670b7292ca158b7bc916c9a6735ac8872834a"
integrity sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=
dependencies:
void-elements "^2.0.1"
html-webpack-plugin@4.0.0-beta.11: html-webpack-plugin@4.0.0-beta.11:
version "4.0.0-beta.11" version "4.0.0-beta.11"
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.0.0-beta.11.tgz#3059a69144b5aecef97708196ca32f9e68677715" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.0.0-beta.11.tgz#3059a69144b5aecef97708196ca32f9e68677715"
@ -6288,6 +6302,26 @@ hyphenate-style-name@^1.0.2:
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48"
integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ== integrity sha512-EcuixamT82oplpoJ2XU4pDtKGWQ7b00CD9f1ug9IaQ3p1bkHMiKCZ9ut9QDI6qsa6cpUuB+A/I+zLtdNK4n2DQ==
i18next-electron-language-detector@^0.0.10:
version "0.0.10"
resolved "https://registry.yarnpkg.com/i18next-electron-language-detector/-/i18next-electron-language-detector-0.0.10.tgz#6fdb01df5a47c40ca3821458449da88220c4baf8"
integrity sha512-l/CdtK5i6BB7h5OGKadUK+Q0q4e4EYXZSDV+Hetxjdv4C8RoYPNbqfTIpcc4RpIO3Dty05Xt8TxV+HyFd6opeA==
i18next-node-fs-backend@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/i18next-node-fs-backend/-/i18next-node-fs-backend-2.1.3.tgz#483fa9eda4c152d62a3a55bcae2a5727ba887559"
integrity sha512-CreMFiVl3ChlMc5ys/e0QfuLFOZyFcL40Jj6jaKD6DxZ/GCUMxPI9BpU43QMWUgC7r+PClpxg2cGXAl0CjG04g==
dependencies:
js-yaml "3.13.1"
json5 "2.0.0"
i18next@^19.3.2:
version "19.3.2"
resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.3.2.tgz#a17c3c8bb0dd2d8c4a8963429df99730275b3282"
integrity sha512-QDBQ8MqFWi4+L9OQjjZEKVyg9uSTy3NTU3Ri53QHe7nxtV+KD4PyLB8Kxu58gr6b9y5l8cU3mCiNHVeoxPMzAQ==
dependencies:
"@babel/runtime" "^7.3.1"
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13: iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -7395,7 +7429,7 @@ js-tokens@^3.0.0, js-tokens@^3.0.2:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^3.13.1: js-yaml@3.13.1, js-yaml@^3.13.1:
version "3.13.1" version "3.13.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
@ -7529,6 +7563,13 @@ json3@^3.3.2:
resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81"
integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA==
json5@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.0.0.tgz#b61abf97aa178c4b5853a66cc8eecafd03045d78"
integrity sha512-0EdQvHuLm7yJ7lyG5dp7Q3X2ku++BG5ZHaJ5FTnaXpKqDrw4pMxel5Bt3oAYMthnrthFBdnZ1FcsXTPyrQlV0w==
dependencies:
minimist "^1.2.0"
json5@^1.0.1: json5@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
@ -10281,6 +10322,14 @@ react-hammerjs@^1.0.1:
dependencies: dependencies:
hammerjs "^2.0.8" hammerjs "^2.0.8"
react-i18next@^11.3.3:
version "11.3.3"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.3.3.tgz#a84dcc32e3ad013012964d836790d8c6afac8e88"
integrity sha512-sGnPwJ0Kf8qTRLTnTRk030KiU6WYEZ49rP9ILPvCnsmgEKyucQfTxab+klSYnCSKYija+CWL+yo+c9va9BmJeg==
dependencies:
"@babel/runtime" "^7.3.1"
html-parse-stringify2 "2.0.1"
react-icons@^3.9.0: react-icons@^3.9.0:
version "3.9.0" version "3.9.0"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-3.9.0.tgz#89a00f20a0e02e6bfd899977eaf46eb4624239d5" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-3.9.0.tgz#89a00f20a0e02e6bfd899977eaf46eb4624239d5"
@ -10639,6 +10688,11 @@ regenerator-runtime@^0.13.2, regenerator-runtime@^0.13.3:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"
integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==
regenerator-runtime@^0.13.4:
version "0.13.4"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.4.tgz#e96bf612a3362d12bb69f7e8f74ffeab25c7ac91"
integrity sha512-plpwicqEzfEyTQohIKktWigcLzmNStMGwbOUbykx51/29Z3JOGYldaaNGK7ngNXV+UcoqvIMmloZ48Sr74sd+g==
regenerator-transform@^0.14.0: regenerator-transform@^0.14.0:
version "0.14.1" version "0.14.1"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"
@ -12674,6 +12728,11 @@ vm-browserify@^1.0.1:
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
void-elements@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
w3c-hr-time@^1.0.1: w3c-hr-time@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045"