diff --git a/.eslintrc b/.eslintrc index d442fc1c..ecfc8026 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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 } } diff --git a/package.json b/package.json index 8d26abe5..d2d5f2d2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 095d4da5..3d578d50 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 - setFileFormat(e.target.value))} {...props}> + {detectedFileFormat && ( )} - + {renderFormatOptions(commonFormatsMap)} - + {renderFormatOptions(otherFormatsMap)} ), [commonFormatsMap, detectedFileFormat, fileFormat, otherFormatsMap]); const renderCaptureFormatButton = useCallback((props) => ( + )} diff --git a/src/HelpSheet.jsx b/src/HelpSheet.jsx index 4cfc7d69..f337bbd0 100644 --- a/src/HelpSheet.jsx +++ b/src/HelpSheet.jsx @@ -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, -}) => ( - - {visible && ( - - +}) => { + const { t } = useTranslation(); -

Keyboard shortcuts

-
H Show/hide this screen
+ return ( + + {visible && ( + + -

Playback

-
SPACE, k Play/pause
-
J Slow down playback
-
L Speed up playback
+

{t('Keyboard shortcuts')}

+
H {t('Show/hide this screen')}
-

Seeking

-
, Step backward 1 frame
-
. Step forward 1 frame
-
ALT / OPT + Seek to previous keyframe
-
ALT / OPT + Seek to next keyframe
-
Seek backward 1 sec
-
Seek forward 1 sec
-
CTRL / CMD + Seek backward 1% of timeline at current zoom
-
CTRL / CMD + Seek forward 1% of timeline at current zoom
+

{t('Playback')}

-

Timeline/zoom operations

-
Z Toggle zoom between 1x and a calculated comfortable zoom level
-
CTRL / CMD + Zoom in timeline
-
CTRL / CMD + Zoom out timeline
-
CTRL + Mouse scroll/wheel up/down - Zoom in/out timeline
-
Mouse scroll/wheel left/right - Pan timeline
-
(For Windows or computers without 2D track pad or left/right mouse wheel scrolling function, use SHIFT + Mouse scroll/wheel up/down)
+
SPACE, k {t('Play/pause')}
+
J {t('Slow down playback')}
+
L {t('Speed up playback')}
-

Segments and cut points

-
I Mark in / cut start point for current segment
-
O Mark out / cut end point for current segment
-
+ Add cut segment
-
BACKSPACE Remove current segment
-
Select previous segment
-
Select next segment
+

{t('Seeking')}

-

File system actions

-
E Export segment(s)
-
C Capture snapshot
-
D Delete source file
+
, {t('Step backward 1 frame')}
+
. {t('Step forward 1 frame')}
+
ALT / OPT + {t('Seek to previous keyframe')}
+
ALT / OPT + {t('Seek to next keyframe')}
+
{t('Seek backward 1 sec')}
+
{t('Seek forward 1 sec')}
+
CTRL / CMD + {t('Seek backward 1% of timeline at current zoom')}
+
CTRL / CMD + {t('Seek forward 1% of timeline at current zoom')}
-

Hover mouse over buttons in the main interface to see which function they have.

+

{t('Timeline/zoom operations')}

+
Z {t('Toggle zoom between 1x and a calculated comfortable zoom level')}
+
CTRL / CMD + {t('Zoom in timeline')}
+
CTRL / CMD + {t('Zoom out timeline')}
+
CTRL + {t('Mouse scroll/wheel up/down')} - {t('Zoom in/out timeline')}
+
{t('Mouse scroll/wheel left/right')} - {t('Pan timeline')}
-

Last ffmpeg commands

- {ffmpegCommandLog.length > 0 ? ( -
- {ffmpegCommandLog.reverse().map(({ command }, i) => ( - // eslint-disable-next-line react/no-array-index-key -
- { clipboard.writeText(command); toast.fire({ timer: 2000, icon: 'success', title: 'Copied to clipboard' }); }} /> {command} -
- ))} -
- ) : ( -

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.

- )} -
- )} -
-)); +

{t('Segments and cut points')}

+
I {t('Mark in / cut start point for current segment')}
+
O {t('Mark out / cut end point for current segment')}
+
+ {t('Add cut segment')}
+
BACKSPACE {t('Remove current segment')}
+
{t('Select previous segment')}
+
{t('Select next segment')}
+ +

{t('File system actions')}

+
E {t('Export segment(s)')}
+
C {t('Capture snapshot')}
+
D {t('Delete source file')}
+ +

{t('Hover mouse over buttons in the main interface to see which function they have')}

+ +

{t('Last ffmpeg commands')}

+ {ffmpegCommandLog.length > 0 ? ( +
+ {ffmpegCommandLog.reverse().map(({ command }, i) => ( + // eslint-disable-next-line react/no-array-index-key +
+ { clipboard.writeText(command); toast.fire({ timer: 2000, icon: 'success', title: t('Copied to clipboard') }); }} /> {command} +
+ ))} +
+ ) : ( +

{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.')}

+ )} +
+ )} +
+ ); +}); export default HelpSheet; diff --git a/src/LeftMenu.jsx b/src/LeftMenu.jsx index bd15ceeb..8a568e6d 100644 --- a/src/LeftMenu.jsx +++ b/src/LeftMenu.jsx @@ -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, -
{Math.floor(zoom)}x
+
{Math.floor(zoom)}x
- setZoom(parseInt(e.target.value, 10)))}> + {zoomOptions.map(val => ( - + ))} diff --git a/src/RightMenu.jsx b/src/RightMenu.jsx index bee8fdce..65980502 100644 --- a/src/RightMenu.jsx +++ b/src/RightMenu.jsx @@ -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 (
@@ -21,14 +24,14 @@ const RightMenu = memo(({
- Export + {t('Export')}
); diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index b4e69cef..261f0df4 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -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
Make sure you have no overlapping segments.
; + return
{t('Make sure you have no overlapping segments.')}
; } if (!cutSegments || cutSegments.length === 0) { - return
No segments to export.
; + return
{t('No segments to export.')}
; } 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(({
- Segments to export: + {t('Segments to export:')}
{cutSegments.map((seg, index) => { @@ -109,7 +112,7 @@ const SegmentList = memo(({
{seg.name}
- Duration {prettyMs(durationMs)} + {t('Duration')} {prettyMs(durationMs)}
({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} />
-
Segments total:
+
{t('Segments total:')}
{formatTimecode(cutSegments.reduce((acc, { start, end }) => (end - start) + acc, 0))}
diff --git a/src/Settings.jsx b/src/Settings.jsx index c82a2a36..4dbdb095 100644 --- a/src/Settings.jsx +++ b/src/Settings.jsx @@ -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) => ; // eslint-disable-next-line react/jsx-props-no-spreading const KeyCell = (props) => ; + function onLangChange(e) { + const { value } = e.target; + const l = value !== '' ? value : undefined; + setLanguage(l); + } + return ( - Output format (default autodetected) + {t('App language')} + + + + + + + {t('Output format (default autodetected)')} {renderOutFmt({ width: '100%' })} - Working directory
- This is where working files, exported files, project files (CSV) are stored. + {t('Working directory')}
+ {t('This is where working files, exported files, project files (CSV) are stored.')}
{customOutDir}
- Auto merge segments to one file during export or export to separate files? + {t('Auto merge segments to one file during export or export to separate files?')} setAutoMerge(value === 'automerge')} /> @@ -44,13 +64,13 @@ const Settings = memo(({ - Keyframe cut mode
- Nearest keyframe: Cut at the nearest keyframe (not accurate time.) Equiv to ffmpeg -ss -i ...
- Normal cut: Accurate time but could leave an empty portion at the beginning of the video. Equiv to ffmpeg -i -ss ...
+ {t('Keyframe cut mode')}
+ {t('Keyframe cut')}: Cut at the nearest keyframe (not accurate time.) Equiv to ffmpeg -ss -i ...
+ {t('Normal cut')}: Accurate time but could leave an empty portion at the beginning of the video. Equiv to ffmpeg -i -ss ...
setKeyframeCut(value === 'keyframe')} /> @@ -59,13 +79,13 @@ const Settings = memo(({ - ☯️ Choose cutting mode: Remove or keep selected segments from video when exporting?
- When Keep is selected, the video inside segments will be kept, while the video outside will be discarded.
- When Remove is selected, the video inside segments will be discarded, while the video surrounding them will be kept. + ☯️ {t('Choose cutting mode: Remove or keep selected segments from video when exporting?')}
+ {t('Keep')}: {t('The video inside segments will be kept, while the video outside will be discarded.')}
+ {t('Remove')}: {t('The video inside segments will be discarded, while the video surrounding them will be kept.')}
setInvertCutSegments(value === 'discard')} /> @@ -74,8 +94,8 @@ const Settings = memo(({ - Extract unprocessable tracks to separate files or discard them?
- (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?')}
+ {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)')}
@@ -84,12 +104,12 @@ const Settings = memo(({ - Auto save project file?
- The project will be stored along with the output files as a CSV file + {t('Auto save project file?')}
+ {t('The project will be stored along with the output files as a CSV file')}
setAutoSaveProjectFile(e.target.checked)} /> @@ -98,7 +118,7 @@ const Settings = memo(({ - Snapshot capture format + {t('Snapshot capture format')} {renderCaptureFormatButton()} @@ -106,10 +126,10 @@ const Settings = memo(({ - In timecode show + {t('In timecode show')} setTimecodeShowFrames(value === 'frames')} /> @@ -117,17 +137,17 @@ const Settings = memo(({ - Scroll/wheel sensitivity + {t('Scroll/wheel sensitivity')} - + - Ask for confirmation when closing app or file? + {t('Ask for confirmation when closing app or file?')} setAskBeforeClose(e.target.checked)} /> diff --git a/src/SettingsSheet.jsx b/src/SettingsSheet.jsx index 6b651474..9158f2ac 100644 --- a/src/SettingsSheet.jsx +++ b/src/SettingsSheet.jsx @@ -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, -}) => ( - - {visible && ( - - +}) => { + const { t } = useTranslation(); - - - - Settings - - - Current setting - - - - {renderSettings()} - -
-
- )} -
-)); + return ( + + {visible && ( + + + + + + + {t('Settings')} + + + {t('Current setting')} + + + + {renderSettings()} + +
+
+ )} +
+ ); +}); export default SettingsSheet; diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx index 2c5adce6..8d174f7d 100644 --- a/src/StreamsSelector.jsx +++ b/src/StreamsSelector.jsx @@ -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 }) => { {stream.nb_frames} {!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`} {stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`} - onInfoClick(stream, 'Stream info')} size={26} /> + onInfoClick(stream, t('Stream info'))} size={26} /> ); }); -function renderFileRow(path, formatData, onTrashClick) { +const FileRow = ({ path, formatData, onTrashClick }) => { + const { t } = useTranslation(); + return ( {onTrashClick && } {path.replace(/.*\/([^/]+)$/, '$1')} - onInfoClick(formatData, 'File info')} size={26} /> + onInfoClick(formatData, t('File info'))} size={26} /> ); -} +}; 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 (
-

Click to select which tracks to keep when exporting:

+

{t('Click to select which tracks to keep when exporting:')}

- + - - - - - - + + + + + + + - {renderFileRow(mainFilePath, mainFileFormatData)} + {existingStreams.map((stream) => ( - {renderFileRow(path, formatData, () => removeFile(path))} + removeFile(path)} /> {streams.map((stream) => ( 0 && !areWeCutting && (
- 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?')}
setShortestFlag(value === 'shortest')} /> @@ -161,18 +168,18 @@ const StreamsSelector = memo(({ {nonCopiedExtraStreams.length > 0 && (
- Discard or extract unprocessable tracks to separate files? + {t('Discard or extract unprocessable tracks to separate files?')}
)}
- Include more tracks from other file + {t('Include more tracks from other file')}
{externalFilesEntries.length === 0 && (
- Export each track as individual files + {t('Export each track as individual files')}
)}
diff --git a/src/Timeline.jsx b/src/Timeline.jsx index 96800dfa..c8c5ec4e 100644 --- a/src/Timeline.jsx +++ b/src/Timeline.jsx @@ -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) && (
- Zoom in more to view waveform + {t('Zoom in more to view waveform')}
)} diff --git a/src/TimelineControls.jsx b/src/TimelineControls.jsx index db520848..1da4267c 100644 --- a/src/TimelineControls.jsx +++ b/src/TimelineControls.jsx @@ -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(({
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(({ 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)} /> @@ -152,30 +155,30 @@ const TimelineControls = memo(({ 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')} seekClosestKeyframe(-1)} /> shortStep(-1)} /> shortStep(1)} /> 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)} seekAbs(duration)} /> diff --git a/src/TopMenu.jsx b/src/TopMenu.jsx index 4550de2d..993ff12f 100644 --- a/src/TopMenu.jsx +++ b/src/TopMenu.jsx @@ -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 && ( )} @@ -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')}
{renderOutFmt({ height: 20 })}
@@ -50,19 +53,19 @@ const TopMenu = memo(({ )} diff --git a/src/edlStore.js b/src/edlStore.js index a5797dfc..5b5431bb 100644 --- a/src/edlStore.js +++ b/src/edlStore.js @@ -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; diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 2b4f9e5a..451189fa 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -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; diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 00000000..6f8c2ab6 --- /dev/null +++ b/src/i18n.js @@ -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; diff --git a/src/index.jsx b/src/index.jsx index 13fbd956..3d28244f 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -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(, document.getElementById('root')); +ReactDOM.render(}>, document.getElementById('root')); diff --git a/src/merge/SortableFiles.jsx b/src/merge/SortableFiles.jsx index 77bca6e4..c3001ad0 100644 --- a/src/merge/SortableFiles.jsx +++ b/src/merge/SortableFiles.jsx @@ -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 (
-
Sort your files for merge
+
{t('Sort your files for merge')}
- setAllStreams(e.target.checked)} label="Include all streams?" /> + setAllStreams(e.target.checked)} label={t('Include all streams?')} />
); diff --git a/src/merge/merge.jsx b/src/merge/merge.jsx index 65934f89..238a19ba 100644 --- a/src/merge/merge.jsx +++ b/src/merge/merge.jsx @@ -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: ( 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, diff --git a/yarn.lock b/yarn.lock index 9f0086f1..8e1bd8a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"
Keep?{t('Keep?')} - TypeTagCodecDurationFramesBitrateData{t('Type')}{t('Tag')}{t('Codec')}{t('Duration')}{t('Frames')}{t('Bitrate')}{t('Data')}