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,
"arrow-parens": 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",
"framer-motion": "^1.8.4",
"hammerjs": "^2.0.8",
"i18next": "^19.3.2",
"lodash": "^4.17.13",
"moment": "^2.18.1",
"mousetrap": "^1.6.1",
@ -60,6 +61,7 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hammerjs": "^1.0.1",
"react-i18next": "^11.3.3",
"react-icons": "^3.9.0",
"react-lottie": "^1.2.3",
"react-scripts": "^3.4.0",
@ -83,6 +85,8 @@
"file-type": "^12.4.0",
"fs-extra": "^8.1.0",
"github-api": "^3.2.2",
"i18next-electron-language-detector": "^0.0.10",
"i18next-node-fs-backend": "^2.1.3",
"mime-types": "^2.1.14",
"read-chunk": "^2.0.0",
"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 Mousetrap from 'mousetrap';
import uuid from 'uuid';
import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp';
@ -18,6 +20,7 @@ import sortBy from 'lodash/sortBy';
import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual';
import TopMenu from './TopMenu';
import HelpSheet from './HelpSheet';
import SettingsSheet from './SettingsSheet';
@ -192,6 +195,12 @@ const App = memo(() => {
useEffect(() => configStore.set('autoSaveProjectFile', autoSaveProjectFile), [autoSaveProjectFile]);
const [wheelSensitivity, setWheelSensitivity] = useState(configStore.get('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
const [helpVisible, setHelpVisible] = useState(false);
@ -238,7 +247,7 @@ const App = memo(() => {
function toggleMute() {
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;
});
}
@ -498,7 +507,7 @@ const App = memo(() => {
await edlStoreSave(edlFilePath, debouncedCutSegments);
lastSavedCutSegmentsRef.current = debouncedCutSegments;
} catch (err) {
errorToast('Failed to save CSV');
errorToast(i18n.t('Failed to save CSV'));
console.error('Failed to save CSV', err);
}
}
@ -570,7 +579,7 @@ const App = memo(() => {
customOutDir, paths, allStreams,
});
} 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);
} finally {
setWorking(false);
@ -761,7 +770,7 @@ const App = memo(() => {
useEffect(() => () => waveform && URL.revokeObjectURL(waveform.url), [waveform]);
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) => {
@ -779,7 +788,7 @@ const App = memo(() => {
await createDummyVideo(filePath);
} catch (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 {
setWorking(false);
}
@ -807,7 +816,7 @@ const App = memo(() => {
if (!filePath) return;
// 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 {
setWorking(true);
@ -815,7 +824,7 @@ const App = memo(() => {
await trash(filePath);
if (html5FriendlyPath) await trash(html5FriendlyPath);
} 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 {
resetState();
}
@ -826,27 +835,27 @@ const App = memo(() => {
const cutClick = useCallback(async () => {
if (working) {
errorToast('I\'m busy');
errorToast(i18n.t('I\'m busy'));
return;
}
if (haveInvalidSegs) {
errorToast('Start time must be before end time');
errorToast(i18n.t('Start time must be before end time'));
return;
}
if (numStreamsToCopy === 0) {
errorToast('No tracks to export!');
errorToast(i18n.t('No tracks to export!'));
return;
}
if (!outSegments) {
errorToast('No segments to export!');
errorToast(i18n.t('No segments to export!'));
return;
}
if (outSegments.length < 1) {
errorToast('No segments to export');
errorToast(i18n.t('No segments to export'));
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) {
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
@ -913,15 +923,15 @@ const App = memo(() => {
const capture = useCallback(async () => {
if (!filePath) return;
if (html5FriendlyPath || dummyVideoPath) {
errorToast('Capture frame from this video not yet implemented');
errorToast(i18n.t('Capture frame from this video not yet implemented'));
return;
}
try {
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) {
console.error(err);
errorToast('Failed to capture frame');
errorToast(i18n.t('Failed to capture frame'));
}
}, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath]);
@ -955,7 +965,7 @@ const App = memo(() => {
.every(row => row.start === undefined || row.end === undefined || row.start < row.end);
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);
@ -963,7 +973,7 @@ const App = memo(() => {
} catch (err) {
if (err.code !== 'ENOENT') {
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]);
@ -971,7 +981,7 @@ const App = memo(() => {
const load = useCallback(async (fp, html5FriendlyPathRequested) => {
console.log('Load', { fp, html5FriendlyPathRequested });
if (working) {
errorToast('Tried to load file while busy');
errorToast(i18n.t('Tried to load file while busy'));
return;
}
@ -984,7 +994,7 @@ const App = memo(() => {
const ff = await getDefaultOutFormat(fp, fd);
if (!ff) {
errorToast('Unable to determine file format');
errorToast(i18n.t('Unable to determine file format'));
return;
}
@ -1023,7 +1033,7 @@ const App = memo(() => {
await loadEdlFile(getEdlFilePath(fp));
} catch (err) {
if (err.exitCode === 1 || err.code === 'ENOENT') {
errorToast('Unsupported file');
errorToast(i18n.t('Unsupported file'));
console.error(err);
return;
}
@ -1124,9 +1134,9 @@ const App = memo(() => {
try {
setWorking(true);
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) {
errorToast('Failed to extract all streams');
errorToast(i18n.t('Failed to extract all streams'));
console.error('Failed to extract all streams', err);
} finally {
setWorking(false);
@ -1160,16 +1170,16 @@ const App = memo(() => {
return;
}
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',
input: 'radio',
inputValue: 'open',
showCancelButton: true,
inputOptions: {
open: 'Open the file instead of the current one. You will lose all unsaved work',
add: 'Include all tracks from the new file',
open: i18n.t('Open the file instead of the current one. You will lose all unsaved work'),
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') {
@ -1200,7 +1210,7 @@ const App = memo(() => {
function closeFile() {
if (!isFileOpened) return;
// 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();
}
@ -1220,7 +1230,7 @@ const App = memo(() => {
await createDummyVideo(filePath);
}
} catch (err) {
errorToast('Failed to html5ify file');
errorToast(i18n.t('Failed to html5ify file'));
console.error('Failed to html5ify file', err);
} finally {
setWorking(false);
@ -1255,22 +1265,22 @@ const App = memo(() => {
async function exportEdlFile() {
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 (await exists(fp)) {
errorToast('File exists, bailing');
errorToast(i18n.t('File exists, bailing'));
return;
}
await edlStoreSave(fp, cutSegments);
} catch (err) {
errorToast('Failed to export CSV');
errorToast(i18n.t('Failed to export CSV'));
console.error('Failed to export CSV', err);
}
}
async function importEdlFile() {
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;
await loadEdlFile(filePaths[0]);
}
@ -1342,26 +1352,26 @@ const App = memo(() => {
const renderOutFmt = useCallback((props) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<Select value={fileFormat || ''} title="Output format" onChange={withBlur(e => setFileFormat(e.target.value))} {...props}>
<option key="disabled1" value="" disabled>Format</option>
<Select value={fileFormat || ''} title={i18n.t('Output format')} onChange={withBlur(e => setFileFormat(e.target.value))} {...props}>
<option key="disabled1" value="" disabled>{i18n.t('Format')}</option>
{detectedFileFormat && (
<option key={detectedFileFormat} value={detectedFileFormat}>
{detectedFileFormat} - {allOutFormats[detectedFileFormat]} (detected)
{detectedFileFormat} - {allOutFormats[detectedFileFormat]} {i18n.t('(detected)')}
</option>
)}
<option key="disabled2" value="" disabled>--- Common formats: ---</option>
<option key="disabled2" value="" disabled>--- {i18n.t('Common formats:')} ---</option>
{renderFormatOptions(commonFormatsMap)}
<option key="disabled3" value="" disabled>--- All formats: ---</option>
<option key="disabled3" value="" disabled>--- {i18n.t('All formats:')} ---</option>
{renderFormatOptions(otherFormatsMap)}
</Select>
), [commonFormatsMap, detectedFileFormat, fileFormat, otherFormatsMap]);
const renderCaptureFormatButton = useCallback((props) => (
<Button
title="Capture frame format"
title={i18n.t('Capture frame format')}
onClick={withBlur(toggleCaptureFormat)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
@ -1372,14 +1382,13 @@ const App = memo(() => {
const AutoExportToggler = useCallback(() => (
<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'}
onChange={value => setAutoExportExtraStreams(value === 'extract')}
/>
), [autoExportExtraStreams]);
const onWheelTunerRequested = useCallback(() => {
console.log('wat');
setSettingsVisible(false);
setWheelTunerVisible(true);
}, []);
@ -1400,13 +1409,15 @@ const App = memo(() => {
setTimecodeShowFrames={setTimecodeShowFrames}
askBeforeClose={askBeforeClose}
setAskBeforeClose={setAskBeforeClose}
language={language}
setLanguage={setLanguage}
renderOutFmt={renderOutFmt}
AutoExportToggler={AutoExportToggler}
renderCaptureFormatButton={renderCaptureFormatButton}
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(() => {
loadMifiLink().then(setMifiLink);
@ -1442,6 +1453,8 @@ const App = memo(() => {
if (thumbnailsEnabled) timelineMode = 'thumbnails';
if (waveformEnabled) timelineMode = 'waveform';
const { t } = useTranslation();
return (
<div>
<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 && (
<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 && (
<div style={{ position: 'relative', margin: '3vmin', width: '60vmin', height: '20vmin' }}>
@ -1525,7 +1538,7 @@ const App = memo(() => {
</div>
<div style={{ marginTop: 10 }}>
WORKING
{t('WORKING')}
</div>
{(cutProgress != null) && (
@ -1568,7 +1581,7 @@ const App = memo(() => {
position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: sideBarWidth, color: 'white',
}}
>
Lossless rotation preview
{t('Lossless rotation preview')}
</div>
)}
@ -1581,7 +1594,7 @@ const App = memo(() => {
}}
>
<VolumeIcon
title="Mute preview? (will not affect output)"
title={t('Mute preview? (will not affect output)')}
size={30}
role="button"
style={{ margin: '0 10px 10px 10px' }}
@ -1590,7 +1603,7 @@ const App = memo(() => {
{!showSideBar && (
<FaAngleLeft
title="Show sidebar"
title={t('Show sidebar')}
size={30}
role="button"
style={{ margin: '0 10px 10px 10px' }}
@ -1729,9 +1742,9 @@ const App = memo(() => {
{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 }}>
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)} />
<Button height={20} intent="success" onClick={() => setWheelTunerVisible(false)}>Done</Button>
<Button height={20} intent="success" onClick={() => setWheelTunerVisible(false)}>{t('Done')}</Button>
</div>
)}
</div>

View File

@ -2,6 +2,7 @@ import React, { memo } from 'react';
import { IoIosCloseCircleOutline } from 'react-icons/io';
import { FaClipboard } from 'react-icons/fa';
import { motion, AnimatePresence } from 'framer-motion';
import { useTranslation } from 'react-i18next';
import { toast } from './util';
@ -10,74 +11,79 @@ const { clipboard } = window.require('electron');
const HelpSheet = memo(({
visible, onTogglePress, ffmpegCommandLog,
}) => (
<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 }} />
}) => {
const { t } = useTranslation();
<h1>Keyboard shortcuts</h1>
<div><kbd>H</kbd> Show/hide this screen</div>
return (
<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>
<div><kbd>SPACE</kbd>, <kbd>k</kbd> Play/pause</div>
<div><kbd>J</kbd> Slow down playback</div>
<div><kbd>L</kbd> Speed up playback</div>
<h1>{t('Keyboard shortcuts')}</h1>
<div><kbd>H</kbd> {t('Show/hide this screen')}</div>
<h2>Seeking</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>{t('Playback')}</h2>
<h2>Timeline/zoom operations</h2>
<div><kbd>Z</kbd> Toggle zoom between 1x and a calculated comfortable zoom level</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> Zoom in timeline</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>
<div><kbd>SPACE</kbd>, <kbd>k</kbd> {t('Play/pause')}</div>
<div><kbd>J</kbd> {t('Slow down playback')}</div>
<div><kbd>L</kbd> {t('Speed up playback')}</div>
<h2>Segments and cut points</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>{t('Seeking')}</h2>
<h2>File system actions</h2>
<div><kbd>E</kbd> Export segment(s)</div>
<div><kbd>C</kbd> Capture snapshot</div>
<div><kbd>D</kbd> Delete source file</div>
<div><kbd>,</kbd> {t('Step backward 1 frame')}</div>
<div><kbd>.</kbd> {t('Step forward 1 frame')}</div>
<div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> {t('Seek to previous keyframe')}</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>
{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="Copy to clipboard" onClick={() => { clipboard.writeText(command); toast.fire({ timer: 2000, icon: 'success', title: 'Copied to clipboard' }); }} /> {command}
</div>
))}
</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>
)}
</motion.div>
)}
</AnimatePresence>
));
<h2>{t('Segments and cut points')}</h2>
<div><kbd>I</kbd> {t('Mark in / cut start point for current segment')}</div>
<div><kbd>O</kbd> {t('Mark out / cut end point for current segment')}</div>
<div><kbd>+</kbd> {t('Add cut segment')}</div>
<div><kbd>BACKSPACE</kbd> {t('Remove current segment')}</div>
<div><kbd></kbd> {t('Select previous segment')}</div>
<div><kbd></kbd> {t('Select next segment')}</div>
<h2>{t('File system actions')}</h2>
<div><kbd>E</kbd> {t('Export segment(s)')}</div>
<div><kbd>C</kbd> {t('Capture snapshot')}</div>
<div><kbd>D</kbd> {t('Delete source file')}</div>
<p style={{ fontWeight: 'bold' }}>{t('Hover mouse over buttons in the main interface to see which function they have')}</p>
<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;

View File

@ -2,16 +2,19 @@ import React, { memo } from 'react';
import { Select } from 'evergreen-ui';
import { motion } from 'framer-motion';
import { FaYinYang } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { withBlur, toast } from './util';
const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments, toggleComfortZoom }) => {
const { t } = useTranslation();
function onYinYangClick() {
setInvertCutSegments(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' });
else toast.fire({ title: 'When you export, selected segments on the timeline will be KEPT - the surrounding areas will be REMOVED.' });
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: t('When you export, selected segments on the timeline will be KEPT - the surrounding areas will be REMOVED.') });
return newVal;
});
}
@ -28,18 +31,18 @@ const LeftMenu = memo(({ zoom, setZoom, invertCutSegments, setInvertCutSegments,
<FaYinYang
size={26}
role="button"
title={invertCutSegments ? 'Discard selected segments' : 'Keep selected segments'}
title={invertCutSegments ? t('Discard selected segments') : t('Keep selected segments')}
onClick={onYinYangClick}
/>
</motion.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)))}>
<option key="" value="" disabled>Zoom</option>
<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>{t('Zoom')}</option>
{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>
</div>

View File

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

View File

@ -3,6 +3,7 @@ import prettyMs from 'pretty-ms';
import { FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown, FaAngleRight } from 'react-icons/fa';
import { motion } from 'framer-motion';
import Swal from 'sweetalert2';
import { useTranslation } from 'react-i18next';
import { saveColor } from './colors';
import { getSegColors } from './util';
@ -13,12 +14,14 @@ const SegmentList = memo(({
updateCurrentSegOrder, addCutSegment, removeCutSegment,
setCurrentSegmentName, currentCutSeg, toggleSideBar,
}) => {
const { t } = useTranslation();
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) {
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);
@ -26,12 +29,12 @@ const SegmentList = memo(({
async function onLabelSegmentPress() {
const { value } = await Swal.fire({
showCancelButton: true,
title: 'Label current segment',
title: t('Label current segment'),
inputValue: currentCutSeg.name,
input: 'text',
inputValidator: (v) => {
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() {
if (cutSegments.length < 2) return;
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`,
input: 'text',
inputValue: currentSegIndex + 1,
showCancelButton: true,
inputValidator: (v) => {
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={{ fontSize: 14, marginBottom: 10 }}>
<FaAngleRight
title="Close sidebar"
title={t('Close sidebar')}
size={18}
style={{ verticalAlign: 'middle', color: 'white' }}
role="button"
onClick={toggleSideBar}
/>
Segments to export:
{t('Segments to export:')}
</div>
{cutSegments.map((seg, index) => {
@ -109,7 +112,7 @@ const SegmentList = memo(({
</div>
<div style={{ fontSize: 12, color: 'white' }}>{seg.name}</div>
<div style={{ fontSize: 13 }}>
Duration {prettyMs(durationMs)}
{t('Duration')} {prettyMs(durationMs)}
</div>
<div style={{ fontSize: 12 }}>
({Math.floor(durationMs)} ms, {getFrameCount(duration)} frames)
@ -124,7 +127,7 @@ const SegmentList = memo(({
size={30}
style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: 'rgba(255, 255, 255, 0.2)' }}
role="button"
title="Add segment"
title={t('Add segment')}
onClick={addCutSegment}
/>
@ -132,13 +135,13 @@ const SegmentList = memo(({
size={30}
style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: cutSegments.length < 2 ? 'rgba(255, 255, 255, 0.2)' : currentSegActiveBgColor }}
role="button"
title={`Delete current segment ${currentSegIndex + 1}`}
title={`${t('Delete current segment')} ${currentSegIndex + 1}`}
onClick={removeCutSegment}
/>
<FaSortNumericDown
size={20}
title="Change segment order"
title={t('Change segment order')}
role="button"
style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }}
onClick={onReorderSegsPress}
@ -146,7 +149,7 @@ const SegmentList = memo(({
<FaTag
size={20}
title="Label segment"
title={t('Label segment')}
role="button"
style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }}
onClick={onLabelSegmentPress}
@ -154,7 +157,7 @@ const SegmentList = memo(({
</div>
<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>
</Fragment>

View File

@ -1,41 +1,61 @@
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(({
setOutputDir, customOutDir, autoMerge, setAutoMerge, keyframeCut, setKeyframeCut, invertCutSegments, setInvertCutSegments,
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
const Row = (props) => <Table.Row height="auto" paddingY={12} {...props} />;
// eslint-disable-next-line react/jsx-props-no-spreading
const KeyCell = (props) => <Table.TextCell textProps={{ whiteSpace: 'auto' }} {...props} />;
function onLangChange(e) {
const { value } = e.target;
const l = value !== '' ? value : undefined;
setLanguage(l);
}
return (
<Fragment>
<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>
</Row>
<Row>
<KeyCell>
Working directory<br />
This is where working files, exported files, project files (CSV) are stored.
{t('Working directory')}<br />
{t('This is where working files, exported files, project files (CSV) are stored.')}
</KeyCell>
<Table.TextCell>
<Button onClick={setOutputDir}>
{customOutDir ? 'Custom working directory' : 'Same directory as input file'}
{customOutDir ? t('Custom working directory') : t('Same directory as input file')}
</Button>
<div>{customOutDir}</div>
</Table.TextCell>
</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>
<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'}
onChange={value => setAutoMerge(value === 'automerge')}
/>
@ -44,13 +64,13 @@ const Settings = memo(({
<Row>
<KeyCell>
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>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 />
{t('Keyframe cut mode')}<br />
<b>{t('Keyframe cut')}</b>: Cut at the nearest keyframe (not accurate time.) Equiv to <i>ffmpeg -ss -i ...</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>
<Table.TextCell>
<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'}
onChange={value => setKeyframeCut(value === 'keyframe')}
/>
@ -59,13 +79,13 @@ const Settings = memo(({
<Row>
<KeyCell>
<span role="img" aria-label="Yin Yang"></span> 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 />
When <b>Remove</b> is selected, the video inside segments will be discarded, while the video surrounding them will be kept.
<span role="img" aria-label="Yin Yang"></span> {t('Choose cutting mode: Remove or keep selected segments from video when exporting?')}<br />
<b>{t('Keep')}</b>: {t('The video inside segments will be kept, while the video outside will be discarded.')}<br />
<b>{t('Remove')}</b>: {t('The video inside segments will be discarded, while the video surrounding them will be kept.')}
</KeyCell>
<Table.TextCell>
<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'}
onChange={value => setInvertCutSegments(value === 'discard')}
/>
@ -74,8 +94,8 @@ const Settings = memo(({
<Row>
<KeyCell>
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('Extract unprocessable tracks to separate files or discard them?')}<br />
{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>
<Table.TextCell>
<AutoExportToggler />
@ -84,12 +104,12 @@ const Settings = memo(({
<Row>
<KeyCell>
Auto save project file?<br />
The project will be stored along with the output files as a CSV file
{t('Auto save project file?')}<br />
{t('The project will be stored along with the output files as a CSV file')}
</KeyCell>
<Table.TextCell>
<Checkbox
label="Auto save project"
label={t('Auto save project')}
checked={autoSaveProjectFile}
onChange={e => setAutoSaveProjectFile(e.target.checked)}
/>
@ -98,7 +118,7 @@ const Settings = memo(({
<Row>
<KeyCell>
Snapshot capture format
{t('Snapshot capture format')}
</KeyCell>
<Table.TextCell>
{renderCaptureFormatButton()}
@ -106,10 +126,10 @@ const Settings = memo(({
</Row>
<Row>
<KeyCell>In timecode show</KeyCell>
<KeyCell>{t('In timecode show')}</KeyCell>
<Table.TextCell>
<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'}
onChange={value => setTimecodeShowFrames(value === 'frames')}
/>
@ -117,17 +137,17 @@ const Settings = memo(({
</Row>
<Row>
<KeyCell>Scroll/wheel sensitivity</KeyCell>
<KeyCell>{t('Scroll/wheel sensitivity')}</KeyCell>
<Table.TextCell>
<Button onClick={onWheelTunerRequested}>Change sensitivity</Button>
<Button onClick={onWheelTunerRequested}>{t('Change sensitivity')}</Button>
</Table.TextCell>
</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>
<Checkbox
label="Ask before closing"
label={t('Ask before closing')}
checked={askBeforeClose}
onChange={e => setAskBeforeClose(e.target.checked)}
/>

View File

@ -2,36 +2,41 @@ import React, { memo } from 'react';
import { IoIosCloseCircleOutline } from 'react-icons/io';
import { motion, AnimatePresence } from 'framer-motion';
import { Table } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
const SettingsSheet = memo(({
visible, onTogglePress, renderSettings,
}) => (
<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 }} />
}) => {
const { t } = useTranslation();
<Table style={{ marginTop: 40 }}>
<Table.Head>
<Table.TextHeaderCell>
Settings
</Table.TextHeaderCell>
<Table.TextHeaderCell>
Current setting
</Table.TextHeaderCell>
</Table.Head>
<Table.Body>
{renderSettings()}
</Table.Body>
</Table>
</motion.div>
)}
</AnimatePresence>
));
return (
<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 }} />
<Table style={{ marginTop: 40 }}>
<Table.Head>
<Table.TextHeaderCell>
{t('Settings')}
</Table.TextHeaderCell>
<Table.TextHeaderCell>
{t('Current setting')}
</Table.TextHeaderCell>
</Table.Head>
<Table.Body>
{renderSettings()}
</Table.Body>
</Table>
</motion.div>
)}
</AnimatePresence>
);
});
export default SettingsSheet;

View File

@ -6,6 +6,7 @@ import { MdSubtitles } from 'react-icons/md';
import Swal from 'sweetalert2';
import { SegmentedControl } from 'evergreen-ui';
import withReactContent from 'sweetalert2-react-content';
import { useTranslation } from 'react-i18next';
import { formatDuration } from './util';
import { getStreamFps } from './ffmpeg';
@ -22,6 +23,8 @@ function onInfoClick(s, title) {
}
const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
const { t } = useTranslation();
const bitrate = parseInt(stream.bit_rate, 10);
const streamDuration = parseInt(stream.duration, 10);
const duration = !Number.isNaN(streamDuration) ? streamDuration : fileDuration;
@ -52,20 +55,22 @@ const Stream = memo(({ stream, onToggle, copyStream, fileDuration }) => {
<td>{stream.nb_frames}</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><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>
);
});
function renderFileRow(path, formatData, onTrashClick) {
const FileRow = ({ path, formatData, onTrashClick }) => {
const { t } = useTranslation();
return (
<tr>
<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><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>
);
}
};
const StreamsSelector = memo(({
mainFilePath, mainFileFormatData, streams: existingStreams, isCopyingStreamId, toggleCopyStreamId,
@ -73,6 +78,8 @@ const StreamsSelector = memo(({
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, areWeCutting,
AutoExportToggler,
}) => {
const { t } = useTranslation();
if (!existingStreams) return null;
function getFormatDuration(formatData) {
@ -94,26 +101,26 @@ const StreamsSelector = memo(({
return (
<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 }}>
<thead style={{ background: 'rgba(0,0,0,0.1)' }}>
<tr>
<th>Keep?</th>
<th>{t('Keep?')}</th>
<th />
<th>Type</th>
<th>Tag</th>
<th>Codec</th>
<th>Duration</th>
<th>Frames</th>
<th>Bitrate</th>
<th>Data</th>
<th>{t('Type')}</th>
<th>{t('Tag')}</th>
<th>{t('Codec')}</th>
<th>{t('Duration')}</th>
<th>{t('Frames')}</th>
<th>{t('Bitrate')}</th>
<th>{t('Data')}</th>
<th />
</tr>
</thead>
<tbody>
{renderFileRow(mainFilePath, mainFileFormatData)}
<FileRow path={mainFilePath} formatData={mainFileFormatData} />
{existingStreams.map((stream) => (
<Stream
@ -129,7 +136,7 @@ const StreamsSelector = memo(({
<Fragment key={path}>
<tr><td colSpan={10} /></tr>
{renderFileRow(path, formatData, () => removeFile(path))}
<FileRow path={path} formatData={formatData} onTrashClick={() => removeFile(path)} />
{streams.map((stream) => (
<Stream
@ -148,10 +155,10 @@ const StreamsSelector = memo(({
{externalFilesEntries.length > 0 && !areWeCutting && (
<div style={{ margin: '10px 0' }}>
<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>
<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'}
onChange={value => setShortestFlag(value === 'shortest')}
/>
@ -161,18 +168,18 @@ const StreamsSelector = memo(({
{nonCopiedExtraStreams.length > 0 && (
<div style={{ margin: '10px 0' }}>
Discard or extract unprocessable tracks to separate files?
{t('Discard or extract unprocessable tracks to separate files?')}
<AutoExportToggler />
</div>
)}
<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>
{externalFilesEntries.length === 0 && (
<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>

View File

@ -2,6 +2,7 @@ import React, { memo, useRef, useMemo, useCallback, useEffect, useState } from '
import { motion } from 'framer-motion';
import Hammer from 'react-hammerjs';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import TimelineSeg from './TimelineSeg';
import InverseCutSegment from './InverseCutSegment';
@ -44,6 +45,8 @@ const Timeline = memo(({
waveform, shouldShowWaveform, shouldShowKeyframes, timelineHeight, thumbnails,
onZoomWindowStartTimeChange, waveformEnabled, thumbnailsEnabled, wheelSensitivity,
}) => {
const { t } = useTranslation();
const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef();
const timelineScrollerSkipEventDebounce = useRef();
@ -241,7 +244,7 @@ const Timeline = memo(({
{(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)' }}>
Zoom in more to view waveform
{t('Zoom in more to view waveform')}
</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 { GiSoundWaves } from 'react-icons/gi';
import { IoMdKey } from 'react-icons/io';
import { useTranslation } from 'react-i18next';
// import useTraceUpdate from 'use-trace-update';
import { getSegColors, parseDuration, formatDuration } from './util';
@ -15,6 +16,8 @@ const TimelineControls = memo(({
playing, shortStep, playCommand, setTimelineMode, hasAudio, hasVideo, timelineMode,
keyframesEnabled, setKeyframesEnabled, seekClosestKeyframe,
}) => {
const { t } = useTranslation();
const {
segActiveBgColor: currentSegActiveBgColor,
segBorderColor: currentSegBorderColor,
@ -53,7 +56,7 @@ const TimelineControls = memo(({
<div
style={{ ...segButtonStyle, height: 10, padding: 4, margin: '0 5px' }}
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)}
>
{newIndex + 1}
@ -102,7 +105,7 @@ const TimelineControls = memo(({
<input
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
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)}
value={isCutTimeManualSet()
? cutTimeManual
@ -123,7 +126,7 @@ const TimelineControls = memo(({
size={24}
style={{ padding: '0 5px', color: timelineMode === 'waveform' ? primaryTextColor : undefined }}
role="button"
title="Show waveform"
title={t('Show waveform')}
onClick={() => setTimelineMode('waveform')}
/>
)}
@ -133,7 +136,7 @@ const TimelineControls = memo(({
size={20}
style={{ padding: '0 5px', color: timelineMode === 'thumbnails' ? primaryTextColor : undefined }}
role="button"
title="Show thumbnails"
title={t('Show thumbnails')}
onClick={() => setTimelineMode('thumbnails')}
/>
@ -141,7 +144,7 @@ const TimelineControls = memo(({
size={16}
style={{ padding: '0 5px', color: keyframesEnabled ? primaryTextColor : undefined }}
role="button"
title="Show keyframes"
title={t('Show keyframes')}
onClick={() => setKeyframesEnabled(v => !v)}
/>
</Fragment>
@ -152,30 +155,30 @@ const TimelineControls = memo(({
<FaStepBackward
size={16}
title="Jump to start of video"
title={t('Jump to start of video')}
role="button"
onClick={() => seekAbs(0)}
/>
{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')}
<IoMdKey
size={20}
role="button"
title="Seek previous keyframe"
title={t('Seek previous keyframe')}
style={{ transform: 'matrix(-1, 0, 0, 1, 0, 0)' }}
onClick={() => seekClosestKeyframe(-1)}
/>
<FaCaretLeft
size={20}
role="button"
title="One frame back"
title={t('One frame back')}
onClick={() => shortStep(-1)}
/>
<PlayPause
@ -186,27 +189,27 @@ const TimelineControls = memo(({
<FaCaretRight
size={20}
role="button"
title="One frame forward"
title={t('One frame forward')}
onClick={() => shortStep(1)}
/>
<IoMdKey
size={20}
role="button"
title="Seek next keyframe"
title={t('Seek next keyframe')}
onClick={() => seekClosestKeyframe(1)}
/>
{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)}
<FaStepForward
size={16}
title="Jump to end of video"
title={t('Jump to end of video')}
role="button"
onClick={() => seekAbs(duration)}
/>

View File

@ -2,6 +2,7 @@ import React, { Fragment, memo } from 'react';
import { IoIosHelpCircle, IoIosSettings } from 'react-icons/io';
import { Button } from 'evergreen-ui';
import { MdCallSplit, MdCallMerge } from 'react-icons/md';
import { useTranslation } from 'react-i18next';
import { withBlur } from './util';
@ -11,6 +12,8 @@ const TopMenu = memo(({
renderOutFmt, outSegments, autoMerge, toggleAutoMerge, keyframeCut, toggleKeyframeCut, toggleHelp,
numStreamsToCopy, numStreamsTotal, setStreamsSelectorShown, toggleSettings,
}) => {
const { t } = useTranslation();
const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit;
return (
@ -18,16 +21,16 @@ const TopMenu = memo(({
{filePath && (
<Fragment>
<Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}>
Tracks ({numStreamsToCopy}/{numStreamsTotal})
{t('Tracks')} ({numStreamsToCopy}/{numStreamsTotal})
</Button>
<Button
iconBefore={copyAnyAudioTrack ? 'volume-up' : 'volume-off'}
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)}
>
{copyAnyAudioTrack ? 'Keep audio' : 'Discard audio'}
{copyAnyAudioTrack ? t('Keep audio') : t('Discard audio')}
</Button>
</Fragment>
)}
@ -42,7 +45,7 @@ const TopMenu = memo(({
onClick={withBlur(setOutputDir)}
title={customOutDir}
>
{`Working dir ${customOutDir ? 'set' : 'unset'}`}
{customOutDir ? t('Working dir set') : t('Working dir unset')}
</Button>
<div style={{ width: 60 }}>{renderOutFmt({ height: 20 })}</div>
@ -50,19 +53,19 @@ const TopMenu = memo(({
<Button
height={20}
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)}
>
<AutoMergeIcon /> {autoMerge ? 'Merge cuts' : 'Separate files'}
<AutoMergeIcon /> {autoMerge ? t('Merge cuts') : t('Separate files')}
</Button>
<Button
height={20}
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)}
>
{keyframeCut ? 'Keyframe cut' : 'Normal cut'}
{keyframeCut ? t('Keyframe cut') : t('Normal cut')}
</Button>
</Fragment>
)}

View File

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

View File

@ -4,6 +4,7 @@ import flatMapDeep from 'lodash/flatMapDeep';
import sum from 'lodash/sum';
import sortBy from 'lodash/sortBy';
import moment from 'moment';
import i18n from 'i18next';
import { formatDuration, getOutPath, transferTimestamps, filenamify } from './util';
@ -43,7 +44,7 @@ function getFfprobePath() {
const subPath = map[platform];
if (!subPath) throw new Error(`Unsupported platform ${platform}`);
if (!subPath) throw new Error(`${i18n.t('Unsupported platform')} ${platform}`);
return isDev
? `node_modules/ffprobe-static/bin/${subPath}`
@ -127,12 +128,12 @@ export function getSafeCutTime(frames, cutTime, nextMode) {
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) {
index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma);
if (index === -1) throw new Error('Failed to find next keyframe');
if (index >= frames.length - 1) throw new Error('We are on the last frame');
if (index === -1) throw new Error(i18n.t('Failed to find next keyframe'));
if (index >= frames.length - 1) throw new Error(i18n.t('We are on the last frame'));
const { time } = frames[index];
if (isCloseTo(time, cutTime)) {
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);
if (index === -1) throw new Error('Failed to find any prev frame');
if (index === 0) throw new Error('We are on the first frame');
if (index === -1) throw new Error(i18n.t('Failed to find any prev frame'));
if (index === 0) throw new Error(i18n.t('We are on the first frame'));
if (index === frames.length - 1) {
// 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
index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma);
if (index === -1) throw new Error('Failed to find any prev keyframe');
if (index === 0) throw new Error('We are on the first keyframe');
if (index === -1) throw new Error(i18n.t('Failed to find any prev keyframe'));
if (index === 0) throw new Error(i18n.t('We are on the first keyframe'));
// Use frame before the found keyframe
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 './main.css';
import App from './App';
import './i18n';
import './main.css';
const electron = window.require('electron');
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';
import { basename } from 'path';
import { Checkbox } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
const rowStyle = {
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);
}, [items]);
const { t } = useTranslation();
return (
<div>
<div><b>Sort your files for merge</b></div>
<div><b>{t('Sort your files for merge')}</b></div>
<SortableContainer
items={items}
onSortEnd={onSortEnd}
@ -53,7 +57,7 @@ const SortableFiles = memo(({
/>
<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>
);

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import padStart from 'lodash/padStart';
import Swal from 'sweetalert2';
import i18n from 'i18next';
import randomColor from './random-color';
@ -84,7 +85,7 @@ export const errorToast = (title) => toast.fire({
export async function showFfmpegFail(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) {
@ -98,8 +99,8 @@ export function filenamify(name) {
export async function promptTimeOffset(inputValue) {
const { value } = await Swal.fire({
title: '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)',
title: i18n.t('Set custom start time offset'),
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',
inputValue: inputValue || '',
showCancelButton: true,

View File

@ -1014,6 +1014,13 @@
dependencies:
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":
version "7.1.2"
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"
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:
version "4.0.0-beta.11"
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"
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:
version "0.4.24"
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"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@^3.13.1:
js-yaml@3.13.1, js-yaml@^3.13.1:
version "3.13.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
@ -7529,6 +7563,13 @@ json3@^3.3.2:
resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81"
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
@ -10281,6 +10322,14 @@ react-hammerjs@^1.0.1:
dependencies:
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:
version "3.9.0"
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"
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:
version "0.14.1"
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"
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045"