mirror of https://github.com/mifi/lossless-cut
parent
759c079747
commit
9b027bc762
@ -1,408 +0,0 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { FaYinYang, FaKeyboard } from 'react-icons/fa';
|
||||
import { GlobeIcon, CleanIcon, CogIcon, Button, Table, NumericalIcon, KeyIcon, FolderCloseIcon, DocumentIcon, TimeIcon, Checkbox, Select } from 'evergreen-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CaptureFormatButton from './components/CaptureFormatButton';
|
||||
import AutoExportToggler from './components/AutoExportToggler';
|
||||
import useUserSettings from './hooks/useUserSettings';
|
||||
import { askForFfPath } from './dialogs';
|
||||
import { isMasBuild, isStoreBuild } from './util';
|
||||
import { langNames } from './util/constants';
|
||||
|
||||
import { getModifierKeyNames } from './hooks/useTimelineScroll';
|
||||
|
||||
|
||||
// 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} />;
|
||||
|
||||
const Header = ({ title }) => (
|
||||
<Row backgroundColor="rgba(0,0,0,0.05)">
|
||||
<Table.TextCell><b>{title}</b></Table.TextCell>
|
||||
<Table.TextCell />
|
||||
</Row>
|
||||
);
|
||||
|
||||
const Settings = memo(({
|
||||
onTunerRequested,
|
||||
onKeyboardShortcutsDialogRequested,
|
||||
askForCleanupChoices,
|
||||
toggleStoreProjectInWorkingDir,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, enableTransferTimestamps, setEnableTransferTimestamps, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, enableOverwriteOutput, setEnableOverwriteOutput, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances } = useUserSettings();
|
||||
|
||||
const onLangChange = useCallback((e) => {
|
||||
const { value } = e.target;
|
||||
const l = value !== '' ? value : undefined;
|
||||
setLanguage(l);
|
||||
}, [setLanguage]);
|
||||
|
||||
const timecodeFormatOptions = useMemo(() => ({
|
||||
frameCount: t('Frame counts'),
|
||||
timecodeWithDecimalFraction: t('Millisecond fractions'),
|
||||
timecodeWithFramesFraction: t('Frame fractions'),
|
||||
}), [t]);
|
||||
|
||||
const onTimecodeFormatClick = useCallback(() => {
|
||||
const keys = Object.keys(timecodeFormatOptions);
|
||||
let index = keys.indexOf(timecodeFormat);
|
||||
if (index === -1 || index >= keys.length - 1) index = 0;
|
||||
else index += 1;
|
||||
setTimecodeFormat(keys[index]);
|
||||
}, [setTimecodeFormat, timecodeFormat, timecodeFormatOptions]);
|
||||
|
||||
const changeCustomFfPath = useCallback(async () => {
|
||||
const newCustomFfPath = await askForFfPath(customFfPath);
|
||||
setCustomFfPath(newCustomFfPath);
|
||||
}, [customFfPath, setCustomFfPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<KeyCell><GlobeIcon style={{ verticalAlign: 'middle', marginRight: '.5em' }} /> App language</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Select value={language || ''} onChange={onLangChange}>
|
||||
<option key="" value="">{t('System language')}</option>
|
||||
{Object.keys(langNames).map((lang) => <option key={lang} value={lang}>{langNames[lang]}</option>)}
|
||||
</Select>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{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>
|
||||
<Button iconBefore={FaYinYang} appearance={invertCutSegments ? 'default' : 'primary'} intent="success" onClick={() => setInvertCutSegments((v) => !v)}>
|
||||
{invertCutSegments ? t('Remove') : t('Keep')}
|
||||
</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Working directory')}<br />
|
||||
{t('This is where working files and exported files are stored.')}
|
||||
</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={customOutDir ? FolderCloseIcon : DocumentIcon} onClick={changeOutDir}>
|
||||
{customOutDir ? t('Custom working directory') : t('Same directory as input file')}...
|
||||
</Button>
|
||||
<div>{customOutDir}</div>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Auto save project file?')}<br />
|
||||
</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Auto save project')}
|
||||
checked={autoSaveProjectFile}
|
||||
onChange={e => setAutoSaveProjectFile(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Store project file (.llc) in the working directory or next to loaded media file?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={storeProjectInWorkingDir ? FolderCloseIcon : DocumentIcon} disabled={!autoSaveProjectFile} onClick={toggleStoreProjectInWorkingDir}>
|
||||
{storeProjectInWorkingDir ? t('Store in working directory') : t('Store next to media file')}
|
||||
</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Keyboard, mouse and input')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Keyboard & mouse shortcuts')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={<FaKeyboard />} onClick={onKeyboardShortcutsDialogRequested}>{t('Keyboard & mouse shortcuts')}</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Mouse wheel zoom modifier key')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Select value={mouseWheelZoomModifierKey} onChange={(e) => setMouseWheelZoomModifierKey(e.target.value)}>
|
||||
{Object.entries(getModifierKeyNames()).map(([key, value]) => (
|
||||
<option key={key} value={key}>{value}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Timeline trackpad/wheel sensitivity')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button onClick={() => onTunerRequested('wheelSensitivity')}>{t('Change value')}</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Timeline keyboard seek speed')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button onClick={() => onTunerRequested('keyboardNormalSeekSpeed')}>{t('Change value')}</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Timeline keyboard seek acceleration')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button onClick={() => onTunerRequested('keyboardSeekAccFactor')}>{t('Change value')}</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Invert timeline trackpad/wheel direction?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Invert direction')}
|
||||
checked={invertTimelineScroll}
|
||||
onChange={e => setInvertTimelineScroll(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Options affecting exported files')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Set file modification date/time of output files to:')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={enableTransferTimestamps ? DocumentIcon : TimeIcon} onClick={() => setEnableTransferTimestamps((v) => !v)}>
|
||||
{enableTransferTimestamps ? t('Source file\'s time') : t('Current time')}
|
||||
</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Keyframe cut mode')}<br />
|
||||
<b>{t('Keyframe cut')}</b>: {t('Cut at the nearest keyframe (not accurate time.) Equiv to')} <i>ffmpeg -ss -i ...</i><br />
|
||||
<b>{t('Normal cut')}</b>: {t('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>
|
||||
<Button iconBefore={keyframeCut ? KeyIcon : undefined} onClick={() => toggleKeyframeCut()}>
|
||||
{keyframeCut ? t('Keyframe cut') : t('Normal cut')}
|
||||
</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Overwrite files when exporting, if a file with the same name as the output file name exists?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Overwrite existing files')}
|
||||
checked={enableOverwriteOutput}
|
||||
onChange={e => setEnableOverwriteOutput(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Cleanup files after export?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={<CleanIcon />} onClick={askForCleanupChoices}>{t('Change preferences')}</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Snapshots and frame extraction')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Snapshot capture format')}
|
||||
</KeyCell>
|
||||
<Table.TextCell>
|
||||
<CaptureFormatButton showIcon />
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Snapshot capture method')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button onClick={() => setCaptureFrameMethod((existing) => (existing === 'ffmpeg' ? 'videotag' : 'ffmpeg'))}>
|
||||
{captureFrameMethod === 'ffmpeg' ? t('FFmpeg') : t('HTML video tag')}
|
||||
</Button>
|
||||
{captureFrameMethod === 'ffmpeg' && <div style={{ whiteSpace: 'initial' }}>{t('FFmpeg capture method might sometimes capture more correct colors, but the captured snapshot might be off by one or more frames, relative to the preview.')}</div>}
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Snapshot capture quality')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<input type="range" min={1} max={1000} style={{ width: 200 }} value={Math.round(captureFrameQuality * 1000)} onChange={(e) => setCaptureFrameQuality(Math.max(Math.min(1, parseInt(e.target.value, 10) / 1000)), 0)} /><br />
|
||||
{Math.round(captureFrameQuality * 100)}%
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('File names of extracted video frames')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={captureFrameFileNameFormat === 'timestamp' ? TimeIcon : NumericalIcon} onClick={() => setCaptureFrameFileNameFormat((existing) => (existing === 'timestamp' ? 'index' : 'timestamp'))}>
|
||||
{captureFrameFileNameFormat === 'timestamp' ? t('Frame timestamp') : t('File number')}
|
||||
</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('In timecode show')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={timecodeFormat === 'frameCount' ? NumericalIcon : TimeIcon} onClick={onTimecodeFormatClick}>
|
||||
{timecodeFormatOptions[timecodeFormat]}
|
||||
</Button>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Prompts and dialogs')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Hide informational notifications?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Check to hide notifications')}
|
||||
checked={!!hideNotifications}
|
||||
onChange={e => setHideNotifications(e.target.checked ? 'all' : undefined)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Ask about what to do when opening a new file when another file is already already open?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Ask on file open')}
|
||||
checked={enableAskForFileOpenAction}
|
||||
onChange={e => setEnableAskForFileOpenAction(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Ask for confirmation when closing app or file?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Ask before closing')}
|
||||
checked={askBeforeClose}
|
||||
onChange={e => setAskBeforeClose(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Ask about importing chapters from opened file?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Ask about chapters')}
|
||||
checked={enableAskForImportChapters}
|
||||
onChange={e => setEnableAskForImportChapters(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Advanced options')} />
|
||||
|
||||
{!isMasBuild && (
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Custom FFmpeg directory (experimental)')}<br />
|
||||
{t('This allows you to specify custom FFmpeg and FFprobe binaries to use. Make sure the "ffmpeg" and "ffprobe" executables exist in the same directory, and then select the directory.')}
|
||||
</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Button iconBefore={CogIcon} onClick={changeCustomFfPath}>
|
||||
{customFfPath ? t('Using external ffmpeg') : t('Using built-in ffmpeg')}
|
||||
</Button>
|
||||
<div>{customFfPath}</div>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{!isStoreBuild && (
|
||||
<Row>
|
||||
<KeyCell>{t('Check for updates on startup?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Check for updates')}
|
||||
checked={enableUpdateCheck}
|
||||
onChange={e => setEnableUpdateCheck(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Allow multiple instances of LosslessCut to run concurrently? (experimental)')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Allow multiple instances')}
|
||||
checked={allowMultipleInstances}
|
||||
onChange={e => setAllowMultipleInstances(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Enable HEVC / H265 hardware decoding (you may need to turn this off if you have problems with HEVC files)')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Enable HEVC hardware decoding')}
|
||||
checked={enableNativeHevc}
|
||||
onChange={e => setEnableNativeHevc(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Enable experimental ffmpeg features flag?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Experimental flag')}
|
||||
checked={ffmpegExperimental}
|
||||
onChange={e => setFfmpegExperimental(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Auto load timecode from file as an offset in the timeline?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Auto load timecode')}
|
||||
checked={autoLoadTimecode}
|
||||
onChange={e => setAutoLoadTimecode(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Try to automatically convert to supported format when opening unsupported file?')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<Checkbox
|
||||
label={t('Auto convert to supported format')}
|
||||
checked={enableAutoHtml5ify}
|
||||
onChange={e => setEnableAutoHtml5ify(e.target.checked)}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{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 />
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Settings;
|
||||
@ -1,6 +1,8 @@
|
||||
export const saveColor = 'hsl(158, 100%, 43%)';
|
||||
export const primaryColor = 'hsl(194, 78%, 47%)';
|
||||
export const primaryTextColor = 'hsla(194, 100%, 66%, 1)';
|
||||
export const saveColor = 'var(--green11)';
|
||||
export const primaryColor = 'var(--cyan9)';
|
||||
export const primaryTextColor = 'var(--cyan11)';
|
||||
// todo darkMode:
|
||||
export const waveformColor = '#ffffff'; // Must be hex because used by ffmpeg
|
||||
export const controlsBackground = '#6b6b6b';
|
||||
export const timelineBackground = '#444';
|
||||
export const controlsBackground = 'var(--gray4)';
|
||||
export const timelineBackground = 'var(--gray2)';
|
||||
export const darkModeTransition = 'background .5s';
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
.sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--whiteA10);
|
||||
color: var(--gray12);
|
||||
backdrop-filter: blur(30px);
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .sheet {
|
||||
background: var(--blackA11);
|
||||
}
|
||||
|
||||
.box {
|
||||
background: var(--whiteA8);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .box {
|
||||
background: var(--blackA8);
|
||||
}
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { primaryTextColor } from '../colors';
|
||||
|
||||
export const highlightedTextStyle = { textDecoration: 'underline', textUnderlineOffset: '.2em', textDecorationColor: primaryTextColor, color: 'var(--gray12)', borderRadius: '.4em', padding: '0 .3em' };
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const HighlightedText = memo(({ children, style, ...props }) => <span {...props} style={{ background: 'rgb(193, 98, 0)', borderRadius: '.4em', padding: '0 .3em', ...style }}>{children}</span>);
|
||||
const HighlightedText = memo(({ children, style, ...props }) => <span {...props} style={{ ...highlightedTextStyle, ...style }}>{children}</span>);
|
||||
|
||||
export default HighlightedText;
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import styles from './Select.module.css';
|
||||
|
||||
const Select = memo((props) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<select className={styles.select} {...props} />
|
||||
));
|
||||
|
||||
export default Select;
|
||||
@ -0,0 +1,18 @@
|
||||
.select {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
line-height: 120%;
|
||||
font-size: .8em;
|
||||
background-color: var(--gray3);
|
||||
color: var(--gray12);
|
||||
border-radius: .3em;
|
||||
padding: 0 .3em;
|
||||
outline: .05em solid var(--gray8);
|
||||
border: .05em solid var(--gray7);
|
||||
|
||||
background-image: url("data:image/svg+xml;utf8,<svg fill='white' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: 100%;
|
||||
background-position-y: 0;
|
||||
background-size: auto 100%;
|
||||
}
|
||||
@ -0,0 +1,387 @@
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { FaYinYang, FaKeyboard } from 'react-icons/fa';
|
||||
import { GlobeIcon, CleanIcon, CogIcon, Button, NumericalIcon, KeyIcon, FolderCloseIcon, DocumentIcon, TimeIcon } from 'evergreen-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CaptureFormatButton from './CaptureFormatButton';
|
||||
import AutoExportToggler from './AutoExportToggler';
|
||||
import Switch from './Switch';
|
||||
import useUserSettings from '../hooks/useUserSettings';
|
||||
import { askForFfPath } from '../dialogs';
|
||||
import { isMasBuild, isStoreBuild } from '../util';
|
||||
import { langNames } from '../util/constants';
|
||||
import styles from './Settings.module.css';
|
||||
import Select from './Select';
|
||||
|
||||
import { getModifierKeyNames } from '../hooks/useTimelineScroll';
|
||||
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const Row = (props) => <tr {...props} />;
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const KeyCell = (props) => <td {...props} />;
|
||||
|
||||
const Header = ({ title }) => (
|
||||
<Row className={styles.header}>
|
||||
<th>{title}</th>
|
||||
<th />
|
||||
</Row>
|
||||
);
|
||||
|
||||
const detailsStyle = { opacity: 0.7, fontSize: '.9em', marginTop: '.3em' };
|
||||
|
||||
const Settings = memo(({
|
||||
onTunerRequested,
|
||||
onKeyboardShortcutsDialogRequested,
|
||||
askForCleanupChoices,
|
||||
toggleStoreProjectInWorkingDir,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, enableTransferTimestamps, setEnableTransferTimestamps, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, enableOverwriteOutput, setEnableOverwriteOutput, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances } = useUserSettings();
|
||||
|
||||
const onLangChange = useCallback((e) => {
|
||||
const { value } = e.target;
|
||||
const l = value !== '' ? value : undefined;
|
||||
setLanguage(l);
|
||||
}, [setLanguage]);
|
||||
|
||||
const timecodeFormatOptions = useMemo(() => ({
|
||||
frameCount: t('Frame counts'),
|
||||
timecodeWithDecimalFraction: t('Millisecond fractions'),
|
||||
timecodeWithFramesFraction: t('Frame fractions'),
|
||||
}), [t]);
|
||||
|
||||
const onTimecodeFormatClick = useCallback(() => {
|
||||
const keys = Object.keys(timecodeFormatOptions);
|
||||
let index = keys.indexOf(timecodeFormat);
|
||||
if (index === -1 || index >= keys.length - 1) index = 0;
|
||||
else index += 1;
|
||||
setTimecodeFormat(keys[index]);
|
||||
}, [setTimecodeFormat, timecodeFormat, timecodeFormatOptions]);
|
||||
|
||||
const changeCustomFfPath = useCallback(async () => {
|
||||
const newCustomFfPath = await askForFfPath(customFfPath);
|
||||
setCustomFfPath(newCustomFfPath);
|
||||
}, [customFfPath, setCustomFfPath]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ margin: '0 2em' }}>
|
||||
<div>{t('Hover mouse over buttons in the main interface to see which function they have')}</div>
|
||||
</div>
|
||||
|
||||
<table style={{ marginTop: 20 }} className={styles.settings}>
|
||||
<thead>
|
||||
<tr className={styles.header}>
|
||||
<th>{t('Settings')}</th>
|
||||
<th style={{ width: 300 }}>{t('Current setting')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Row>
|
||||
<KeyCell><GlobeIcon style={{ verticalAlign: 'middle', marginRight: '.5em' }} /> App language</KeyCell>
|
||||
<td>
|
||||
<Select value={language || ''} onChange={onLangChange} style={{ fontSize: '1.2em' }}>
|
||||
<option key="" value="">{t('System language')}</option>
|
||||
{Object.keys(langNames).map((lang) => <option key={lang} value={lang}>{langNames[lang]}</option>)}
|
||||
</Select>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Choose cutting mode: Remove or keep selected segments from video when exporting?')}<br />
|
||||
<div style={detailsStyle}>
|
||||
<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.')}
|
||||
</div>
|
||||
</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={FaYinYang} appearance={invertCutSegments ? 'default' : 'primary'} intent="success" onClick={() => setInvertCutSegments((v) => !v)}>
|
||||
{invertCutSegments ? t('Remove') : t('Keep')}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Working directory')}<br />
|
||||
<div style={detailsStyle}>
|
||||
{t('This is where working files and exported files are stored.')}
|
||||
</div>
|
||||
</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={customOutDir ? FolderCloseIcon : DocumentIcon} onClick={changeOutDir}>
|
||||
{customOutDir ? t('Custom working directory') : t('Same directory as input file')}...
|
||||
</Button>
|
||||
<div>{customOutDir}</div>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Auto save project file?')}<br />
|
||||
</KeyCell>
|
||||
<td>
|
||||
<Switch checked={autoSaveProjectFile} onCheckedChange={setAutoSaveProjectFile} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Store project file (.llc) in the working directory or next to loaded media file?')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={storeProjectInWorkingDir ? FolderCloseIcon : DocumentIcon} disabled={!autoSaveProjectFile} onClick={toggleStoreProjectInWorkingDir}>
|
||||
{storeProjectInWorkingDir ? t('Store in working directory') : t('Store next to media file')}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Keyboard, mouse and input')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Keyboard & mouse shortcuts')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={<FaKeyboard />} onClick={onKeyboardShortcutsDialogRequested}>{t('Keyboard & mouse shortcuts')}</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Mouse wheel zoom modifier key')}</KeyCell>
|
||||
<td>
|
||||
<Select value={mouseWheelZoomModifierKey} onChange={(e) => setMouseWheelZoomModifierKey(e.target.value)}>
|
||||
{Object.entries(getModifierKeyNames()).map(([key, value]) => (
|
||||
<option key={key} value={key}>{value}</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Timeline trackpad/wheel sensitivity')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={CogIcon} onClick={() => onTunerRequested('wheelSensitivity')}>{t('Change value')}</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Timeline keyboard seek speed')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={CogIcon} onClick={() => onTunerRequested('keyboardNormalSeekSpeed')}>{t('Change value')}</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Timeline keyboard seek acceleration')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={CogIcon} onClick={() => onTunerRequested('keyboardSeekAccFactor')}>{t('Change value')}</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Invert timeline trackpad/wheel direction?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={invertTimelineScroll} onCheckedChange={setInvertTimelineScroll} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Options affecting exported files')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Set file modification date/time of output files to:')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={enableTransferTimestamps ? DocumentIcon : TimeIcon} onClick={() => setEnableTransferTimestamps((v) => !v)}>
|
||||
{enableTransferTimestamps ? t('Source file\'s time') : t('Current time')}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Keyframe cut mode')}<br />
|
||||
<div style={detailsStyle}>
|
||||
<b>{t('Keyframe cut')}</b>: {t('Cut at the nearest keyframe (not accurate time.) Equiv to')} <i>ffmpeg -ss -i ...</i><br />
|
||||
<b>{t('Normal cut')}</b>: {t('Accurate time but could leave an empty portion at the beginning of the video. Equiv to')} <i>ffmpeg -i -ss ...</i><br />
|
||||
</div>
|
||||
</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={keyframeCut ? KeyIcon : undefined} onClick={() => toggleKeyframeCut()}>
|
||||
{keyframeCut ? t('Keyframe cut') : t('Normal cut')}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Overwrite files when exporting, if a file with the same name as the output file name exists?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={enableOverwriteOutput} onCheckedChange={setEnableOverwriteOutput} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Cleanup files after export?')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={<CleanIcon />} onClick={askForCleanupChoices}>{t('Change preferences')}</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Snapshots and frame extraction')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Snapshot capture format')}
|
||||
</KeyCell>
|
||||
<td>
|
||||
<CaptureFormatButton showIcon />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Snapshot capture method')}
|
||||
<div style={detailsStyle}>{t('FFmpeg capture method might sometimes capture more correct colors, but the captured snapshot might be off by one or more frames, relative to the preview.')}</div>
|
||||
</KeyCell>
|
||||
<td>
|
||||
<Button onClick={() => setCaptureFrameMethod((existing) => (existing === 'ffmpeg' ? 'videotag' : 'ffmpeg'))}>
|
||||
{captureFrameMethod === 'ffmpeg' ? t('FFmpeg') : t('HTML video tag')}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Snapshot capture quality')}</KeyCell>
|
||||
<td>
|
||||
<input type="range" min={1} max={1000} style={{ width: 200 }} value={Math.round(captureFrameQuality * 1000)} onChange={(e) => setCaptureFrameQuality(Math.max(Math.min(1, parseInt(e.target.value, 10) / 1000)), 0)} /><br />
|
||||
{Math.round(captureFrameQuality * 100)}%
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('File names of extracted video frames')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={captureFrameFileNameFormat === 'timestamp' ? TimeIcon : NumericalIcon} onClick={() => setCaptureFrameFileNameFormat((existing) => (existing === 'timestamp' ? 'index' : 'timestamp'))}>
|
||||
{captureFrameFileNameFormat === 'timestamp' ? t('Frame timestamp') : t('File number')}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('In timecode show')}</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={timecodeFormat === 'frameCount' ? NumericalIcon : TimeIcon} onClick={onTimecodeFormatClick}>
|
||||
{timecodeFormatOptions[timecodeFormat]}
|
||||
</Button>
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Prompts and dialogs')} />
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Show informational notifications')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={!hideNotifications} onCheckedChange={(v) => setHideNotifications(v ? 'all' : undefined)} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Ask about what to do when opening a new file when another file is already already open?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={enableAskForFileOpenAction} onCheckedChange={setEnableAskForFileOpenAction} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Ask for confirmation when closing app or file?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={askBeforeClose} onCheckedChange={setAskBeforeClose} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Ask about importing chapters from opened file?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={enableAskForImportChapters} onCheckedChange={setEnableAskForImportChapters} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Header title={t('Advanced options')} />
|
||||
|
||||
{!isMasBuild && (
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Custom FFmpeg directory (experimental)')}<br />
|
||||
<div style={detailsStyle}>
|
||||
{t('This allows you to specify custom FFmpeg and FFprobe binaries to use. Make sure the "ffmpeg" and "ffprobe" executables exist in the same directory, and then select the directory.')}
|
||||
</div>
|
||||
</KeyCell>
|
||||
<td>
|
||||
<Button iconBefore={CogIcon} onClick={changeCustomFfPath}>
|
||||
{customFfPath ? t('Using external ffmpeg') : t('Using built-in ffmpeg')}
|
||||
</Button>
|
||||
<div>{customFfPath}</div>
|
||||
</td>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{!isStoreBuild && (
|
||||
<Row>
|
||||
<KeyCell>{t('Check for updates on startup?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={enableUpdateCheck} onCheckedChange={setEnableUpdateCheck} />
|
||||
</td>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Allow multiple instances of LosslessCut to run concurrently? (experimental)')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={allowMultipleInstances} onCheckedChange={setAllowMultipleInstances} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Enable HEVC / H265 hardware decoding (you may need to turn this off if you have problems with HEVC files)')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={enableNativeHevc} onCheckedChange={setEnableNativeHevc} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Enable experimental ffmpeg features flag?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={ffmpegExperimental} onCheckedChange={setFfmpegExperimental} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Auto load timecode from file as an offset in the timeline?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={autoLoadTimecode} onCheckedChange={setAutoLoadTimecode} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Try to automatically convert to supported format when opening unsupported file?')}</KeyCell>
|
||||
<td>
|
||||
<Switch checked={enableAutoHtml5ify} onCheckedChange={setEnableAutoHtml5ify} />
|
||||
</td>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Extract unprocessable tracks to separate files or discard them?')}<br />
|
||||
<div style={detailsStyle}>
|
||||
{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)')}
|
||||
</div>
|
||||
</KeyCell>
|
||||
<td>
|
||||
<AutoExportToggler />
|
||||
</td>
|
||||
</Row>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default Settings;
|
||||
@ -0,0 +1,22 @@
|
||||
.settings td:first-child, .settings th:first-child {
|
||||
padding: 1em 2em 1em 2em;
|
||||
}
|
||||
.settings td:nth-child(2), .settings th:nth-child(2) {
|
||||
padding: 1em 2em 1em 0em;
|
||||
}
|
||||
|
||||
.settings th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings tr.header {
|
||||
background-color: var(--blackA3);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .settings tr.header {
|
||||
background-color: var(--whiteA6);
|
||||
}
|
||||
|
||||
.settings {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
.sheet {
|
||||
padding: 1em 2em;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--whiteA10);
|
||||
color: var(--gray12);
|
||||
backdrop-filter: blur(30px);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .sheet {
|
||||
background: var(--blackA11);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import * as RadixSwitch from '@radix-ui/react-switch';
|
||||
|
||||
import classes from './Switch.module.css';
|
||||
|
||||
const Switch = ({ checked, disabled, onCheckedChange }) => (
|
||||
<RadixSwitch.Root disabled={disabled} className={classes.SwitchRoot} checked={checked} onCheckedChange={onCheckedChange}>
|
||||
<RadixSwitch.Thumb className={classes.SwitchThumb} />
|
||||
</RadixSwitch.Root>
|
||||
);
|
||||
|
||||
export default Switch;
|
||||
@ -0,0 +1,37 @@
|
||||
.SwitchRoot {
|
||||
all: unset;
|
||||
width: 42px;
|
||||
height: 25px;
|
||||
background-color: var(--gray9);
|
||||
border-radius: 9999px;
|
||||
position: relative;
|
||||
box-shadow: 0 0 0 2px var(--blackA5);
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.SwitchRoot:focus {
|
||||
box-shadow: 0 0 0 2px var(--gray11);
|
||||
}
|
||||
.SwitchRoot[data-state='checked'] {
|
||||
background-color: var(--cyan9);
|
||||
}
|
||||
.SwitchRoot[data-state='checked']:focus {
|
||||
box-shadow: 0 0 0 2px var(--cyan11);
|
||||
}
|
||||
|
||||
.SwitchThumb {
|
||||
display: block;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
background-color: white;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 2px rgba(0,0,0,0.2);
|
||||
transition: transform 100ms;
|
||||
transform: translateX(2px);
|
||||
will-change: transform;
|
||||
}
|
||||
.SwitchThumb[data-state='checked'] {
|
||||
transform: translateX(19px);
|
||||
}
|
||||
.SwitchRoot:disabled {
|
||||
opacity: .5;
|
||||
}
|
||||
Loading…
Reference in New Issue