ui improvements

pull/2465/head
Mikael Finstad 1 year ago
parent 250507d8f4
commit e8bb18c6be
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -7,7 +7,7 @@ import { ReactSortable } from 'react-sortablejs';
import { SortAlphabeticalIcon, SortAlphabeticalDescIcon } from 'evergreen-ui';
import BatchFile from './BatchFile';
import { controlsBackground, darkModeTransition } from '../colors';
import { controlsBackground, darkModeTransition, primaryColor } from '../colors';
import { mySpring } from '../animations';
import { BatchFile as BatchFileType } from '../types';
@ -70,8 +70,8 @@ function BatchFilesList({ selectedBatchFiles, filePath, width, batchFiles, setBa
<div>{t('Batch file list')}{batchFiles.length > 0 && ` (${batchFiles.length})`}</div>
<div style={{ flexGrow: 1 }} />
<FaHatWizard size={17} role="button" title={`${t('Convert to supported format')}...`} style={iconStyle} onClick={onBatchConvertToSupportedFormatClick} />
<AiOutlineMergeCells size={20} role="button" title={`${t('Merge/concatenate files')}...`} style={iconStyle} onClick={onMergeFilesClick} />
<SortIcon size={25} role="button" title={t('Sort items')} style={iconStyle} onClick={onSortClick} />
<AiOutlineMergeCells size={20} role="button" title={`${t('Merge/concatenate files')}...`} style={{ ...iconStyle, color: 'white', background: primaryColor, borderRadius: '.5em' }} onClick={onMergeFilesClick} />
<FaTimes size={20} role="button" title={t('Close batch')} style={{ ...iconStyle, color: 'var(--gray-11)' }} onClick={closeBatch} />
</div>

@ -5,9 +5,9 @@ import styles from './Button.module.css';
export type ButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ type = 'button', ...props }, ref) => (
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ type = 'button', className, ...props }, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading, react/button-has-type
<button ref={ref} className={styles['button']} type={type} {...props} />
<button ref={ref} className={[...(className ? [className] : []), styles['button']].join(' ')} type={type} {...props} />
));
export default Button;

@ -15,12 +15,12 @@ import useUserSettings from '../hooks/useUserSettings';
import { isMov } from '../util/streams';
import { getOutDir, getOutFileExtension } from '../util';
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../../ffprobe';
import Sheet from './Sheet';
import TextInput from './TextInput';
import Button from './Button';
import { defaultMergedFileTemplate, generateMergedFileNames, maxFileNameLength } from '../util/outputNameTemplate';
import Dialog from './Dialog';
import { primaryColor } from '../colors';
import ExportDialog from './ExportDialog';
const { basename } = window.require('path');
@ -193,45 +193,55 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
const onOutputFormatUserChange = useCallback((newFormat: string) => setFileFormat(newFormat), [setFileFormat]);
const onConcatClick = useCallback(() => {
if (outFileName == null) throw new Error();
if (fileFormat == null) throw new Error();
invariant(outFileName != null);
invariant(fileFormat != null);
onConcat({ paths, includeAllStreams, streams: fileMeta!.streams, outFileName, fileFormat, clearBatchFilesAfterConcat });
}, [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, onConcat, outFileName, paths]);
return (
<>
<Sheet visible={isShown} onClosePress={onHide} maxWidth="100%" style={{ padding: '0 2em' }}>
<h1>{t('Merge/concatenate files')}</h1>
<div style={{ marginBottom: '1em' }}>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '.9em', marginBottom: '1em' }}>
{t('This dialog can be used to concatenate files in series, e.g. one after the other:\n[file1][file2][file3]\nIt can NOT be used for merging tracks in parallell (like adding an audio track to a video).\nMake sure all files are of the exact same codecs & codec parameters (fps, resolution etc).')}
</div>
<div style={{ backgroundColor: 'var(--gray-1)', borderRadius: '.1em' }}>
{paths.map((path, index) => (
<div key={path} style={rowStyle} title={path}>
<div>
{index + 1}
{'. '}
<span>{basename(path)}</span>
{!allFilesMetaCache[path] && <FaQuestionCircle style={{ color: 'var(--orange-8)', verticalAlign: 'middle', marginLeft: '1em' }} />}
{problemsByFile[path] && <IconButton appearance="minimal" icon={FaExclamationTriangle} onClick={() => onProblemsByFileClick(path)} title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange-8)', marginLeft: '1em' }} />}
</div>
<ExportDialog
visible={isShown}
title={t('Merge/concatenate files')}
onClosePress={onHide}
renderButton={() => (
<Button className="export-animation" disabled={detectedFileFormat == null || !isOutFileNameValid} onClick={onConcatClick} style={{ fontSize: '1.3em', padding: '0 .3em', marginLeft: '1em', background: primaryColor, color: 'white', border: 'none' }}>
<AiOutlineMergeCells style={{ fontSize: '1.4em', verticalAlign: 'middle' }} /> {t('Merge!')}
</Button>
)}
width="70em"
>
<div style={{ marginBottom: '1em' }}>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '.9em', marginBottom: '1em' }}>
{t('This dialog can be used to concatenate files in series, e.g. one after the other:\n[file1][file2][file3]\nIt can NOT be used for merging tracks in parallell (like adding an audio track to a video).\nMake sure all files are of the exact same codecs & codec parameters (fps, resolution etc).')}
</div>
<div style={{ backgroundColor: 'var(--gray-1)', borderRadius: '.1em' }}>
{paths.map((path, index) => (
<div key={path} style={rowStyle} title={path}>
<div>
<span style={{ opacity: 0.7, marginRight: '.4em' }}>{`${index + 1}.`}</span>
<span>{basename(path)}</span>
{!allFilesMetaCache[path] && <FaQuestionCircle style={{ color: 'var(--orange-8)', verticalAlign: 'middle', marginLeft: '1em' }} />}
{problemsByFile[path] && <IconButton appearance="minimal" icon={FaExclamationTriangle} onClick={() => onProblemsByFileClick(path)} title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange-8)', marginLeft: '1em' }} />}
</div>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', marginBottom: '.5em', gap: '.5em' }}>
<Checkbox checked={enableReadFileMeta} onCheckedChange={(checked) => setEnableReadFileMeta(!!checked)} label={t('Check compatibility')} />
<div style={{ marginBottom: '1em' }}>
<Checkbox style={{ marginBottom: '.7em' }} checked={enableReadFileMeta} onCheckedChange={(checked) => setEnableReadFileMeta(!!checked)} label={t('Check compatibility')} />
<Button onClick={() => setSettingsVisible(true)} style={{ height: '1.7em' }}><FaCog style={{ fontSize: '1em', verticalAlign: 'middle' }} /> {t('Options')}</Button>
<Button onClick={() => setSettingsVisible(true)} style={{ padding: '.3em .5em', marginBottom: '.5em' }}><FaCog style={{ verticalAlign: 'top', fontSize: '1.4em', marginRight: '.2em' }} /> {t('Options')}</Button>
{fileFormat && detectedFileFormat && (
<OutputFormatSelect style={{ height: '1.7em', maxWidth: '20em' }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />
)}
</div>
<div>{t('Output container format:')}</div>
{fileFormat && detectedFileFormat && (
<OutputFormatSelect style={{ height: '1.7em', maxWidth: '20em', marginBottom: '.7em' }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />
)}
<div style={{ marginBottom: '.3em' }}>{t('Output file name')}:</div>
<TextInput style={{ width: '100%', fontSize: '1.2em', padding: '.1em .3em', marginBottom: '.7em' }} value={outFileName || ''} onChange={(e) => setOutFileName(e.target.value)} />
{isOutFileNameTooLong && (
<Alert text={t('File name is too long and cannot be exported.')} />
@ -242,15 +252,7 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
{!enableReadFileMeta && (
<Alert text={t('File compatibility check is not enabled, so the merge operation might not produce a valid output. Enable "Check compatibility" below to check file compatibility before merging.')} />
)}
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end', marginBottom: '1em' }}>
<div style={{ marginRight: '.5em' }}>{t('Output file name')}:</div>
<TextInput value={outFileName || ''} onChange={(e) => setOutFileName(e.target.value)} />
<Button disabled={detectedFileFormat == null || !isOutFileNameValid} onClick={onConcatClick} style={{ fontSize: '1.3em', padding: '0 .3em', marginLeft: '1em', background: primaryColor, color: 'white', border: 'none' }}>
<AiOutlineMergeCells style={{ fontSize: '1.4em', verticalAlign: 'middle' }} /> {t('Merge!')}
</Button>
</div>
</Sheet>
</div>
{settingsVisible && (
<Dialog autoOpen onClose={() => setSettingsVisible(false)} style={{ maxWidth: '40em' }}>
@ -271,7 +273,7 @@ function ConcatDialog({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFi
<p>{t('Note that also other settings from the normal export dialog apply to this merge function. For more information about all options, see the export dialog.')}</p>
</Dialog>
)}
</>
</ExportDialog>
);
}

@ -31,6 +31,7 @@ function ExportButton({ segmentsToExport, areWeCutting, onClick, size = 1 }: {
return (
<div
className="export-animation"
style={{ cursor: 'pointer', background: primaryColor, color: 'white', borderRadius: size * 5, paddingTop: size * 1, paddingBottom: size * 2.5, paddingLeft: size * 7, paddingRight: size * 7, fontSize: size * 13, whiteSpace: 'nowrap' }}
onClick={onClick}
title={title}

@ -1,50 +1,16 @@
.sheet {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: var(--white-a11);
color: var(--gray-12);
backdrop-filter: blur(30px);
overflow-y: scroll;
display: flex;
justify-content: center;
align-items: flex-start;
table.options {
width: 100%;
}
table.options td:nth-child(2) {
text-align: right;
}
table.options td:last-child {
text-align: center;
width: 1.7em;
}
table.options td {
vertical-align: top;
}
table.options {
width: 100%;
}
:global(.dark-theme) .sheet {
background: var(--black-a11);
table.options td:nth-child(2) {
text-align: right;
}
.box {
margin: 15px 15px 50px 15px;
border-radius: 10px;
padding: 10px 20px;
min-height: 500px;
position: relative;
max-width: 100%;
width: 50em;
background: var(--white-a11);
table.options td:last-child {
text-align: center;
width: 1.7em;
}
:global(.dark-theme) .box {
background: var(--black-a11);
table.options td {
vertical-align: top;
}

@ -1,5 +1,4 @@
import { CSSProperties, Dispatch, ReactNode, SetStateAction, memo, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaExclamationTriangle, FaInfoCircle, FaRegCheckCircle } from 'react-icons/fa';
import i18n from 'i18next';
import { useTranslation, Trans } from 'react-i18next';
@ -8,7 +7,6 @@ import type { SweetAlertIcon } from 'sweetalert2';
import ExportButton from './ExportButton';
import ExportModeButton from './ExportModeButton';
import ToggleExportConfirm from './ToggleExportConfirm';
import FileNameTemplateEditor from './FileNameTemplateEditor';
import HighlightedText, { highlightedTextStyle } from './HighlightedText';
import Select from './Select';
@ -26,7 +24,8 @@ import { FFprobeStream } from '../../../../ffprobe';
import { AvoidNegativeTs, PreserveMetadata } from '../../../../types';
import TextInput from './TextInput';
import { UseSegments } from '../hooks/useSegments';
import CloseButton from './CloseButton';
import ExportDialog from './ExportDialog';
import ToggleExportConfirm from './ToggleExportConfirm';
const outDirStyle: CSSProperties = { ...highlightedTextStyle, wordBreak: 'break-all', cursor: 'pointer' };
@ -142,8 +141,6 @@ function ExportConfirm({
const areWeCuttingProblematicStreams = areWeCutting && mainCopiedThumbnailStreams.length > 0;
const notices = useMemo(() => {
const generic: { warning?: true, text: string }[] = [];
const specific: Record<'exportMode' | 'problematicStreams' | 'movFastStart' | 'preserveMovData' | 'smartCut' | 'cutMode' | 'avoidNegativeTs' | 'overwriteOutput', { warning?: true, text: ReactNode } | undefined> = {
exportMode: effectiveExportMode === 'segments_to_chapters' ? { text: i18n.t('Segments to chapters mode is active, this means that the file will not be cut. Instead chapters will be created from the segments.') } : undefined,
problematicStreams: areWeCuttingProblematicStreams ? { warning: true, text: <Trans>Warning: Cutting thumbnail tracks is known to cause problems. Consider disabling track {{ trackNumber: mainCopiedThumbnailStreams[0] ? mainCopiedThumbnailStreams[0].index + 1 : 0 }}.</Trans> } : undefined,
@ -151,23 +148,23 @@ function ExportConfirm({
preserveMovData: isMov && isIpod && preserveMovData ? { warning: true, text: t('For the ipod format, it is recommended to deactivate this option') } : undefined,
smartCut: areWeCutting && needSmartCut ? { warning: true, text: t('Smart cut is experimental and will not work on all files.') } : undefined,
cutMode: areWeCutting && !needSmartCut && !keyframeCut ? { text: t('Note: Keyframe cut is recommended for most common files') } : undefined,
avoidNegativeTs: !needSmartCut ? {
text: (() => {
if (willMerge) {
if (avoidNegativeTs !== 'make_non_negative') {
return t('When merging, it\'s generally recommended to set this to "make_non_negative"');
}
return undefined;
}
if (!['make_zero', 'auto'].includes(avoidNegativeTs)) {
return t('It\'s generally recommended to set this to one of: {{values}}', { values: '"auto", "make_zero"' });
avoidNegativeTs: !needSmartCut ? (() => {
if (willMerge) {
if (avoidNegativeTs !== 'make_non_negative') {
return { text: t('When merging, it\'s generally recommended to set this to "make_non_negative"') };
}
return undefined;
})(),
} : undefined,
}
if (!['make_zero', 'auto'].includes(avoidNegativeTs)) {
return { text: t('It\'s generally recommended to set this to one of: {{values}}', { values: '"auto", "make_zero"' }) };
}
return undefined;
})() : undefined,
overwriteOutput: enableOverwriteOutput ? { text: t('Existing files will be overwritten without warning!') } : undefined,
};
const generic: { warning?: true, text: string }[] = [];
if (effectiveExportMode === 'separate' && !areWeCutting) {
generic.push({ text: t('Exporting whole file without cutting, because there are no segments to export.') });
}
@ -279,382 +276,352 @@ function ExportConfirm({
setSmartCutBitrate(v);
}, [setSmartCutBitrate]);
// https://stackoverflow.com/questions/33454533/cant-scroll-to-top-of-flex-item-that-is-overflowing-container
return (
<AnimatePresence>
{visible && (
<ExportDialog
width="50em"
visible={visible}
title={t('Export options')}
onClosePress={onClosePress}
renderButton={() => (
<ExportButton segmentsToExport={segmentsToExport} areWeCutting={areWeCutting} onClick={() => onExportConfirm()} size={1.7} />
)}
renderBottom={() => (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={styles['sheet']}
transition={{ duration: 0.3, easings: ['easeOut'] }}
>
<div className={styles['box']}>
<h1 style={{ textTransform: 'uppercase', fontSize: '1.4em', marginTop: 0, marginBottom: '.5em' }}>{t('Export options')}</h1>
<CloseButton type="submit" style={{ top: 0, right: 0 }} onClick={onClosePress} />
<table className={styles['options']}>
<tbody>
{notices.generic.map(({ warning, text }) => (
<tr key={text}>
<td colSpan={2}>
<div style={{ ...(warning ? { ...noticeStyle, color: 'var(--orange-8)' } : infoStyle), display: 'flex', alignItems: 'center', gap: '0 .5em' }}>
{renderNotice({ warning, text })}
</div>
</td>
<td />
</tr>
))}
{segmentsOrInverse.selected.length !== segmentsOrInverse.all.length && (
<tr>
<td colSpan={2}>
<FaRegCheckCircle size={12} style={{ marginRight: 3 }} />{t('{{selectedSegments}} of {{nonFilteredSegments}} segments selected', { selectedSegments: segmentsOrInverse.selected.length, nonFilteredSegments: segmentsOrInverse.all.length })}
</td>
<td />
</tr>
)}
<tr>
<td>
{segmentsOrInverse.selected.length > 1 ? t('Export mode for {{segments}} segments', { segments: segmentsOrInverse.selected.length }) : t('Export mode')}
{renderNotice(notices.specific['exportMode'])}
</td>
<td>
<ExportModeButton selectedSegments={segmentsOrInverse.selected} />
</td>
<td>
{renderNoticeIcon(notices.specific['exportMode'], rightIconStyle) ?? <HelpIcon onClick={onExportModeHelpPress} />}
</td>
</tr>
<tr>
<td>
{t('Output container format:')}
</td>
<td>
{renderOutFmt({ height: 20, maxWidth: 150 })}
</td>
<td>
<HelpIcon onClick={onOutFmtHelpPress} />
</td>
</tr>
<tr>
<td>
<Trans>Input has {{ numStreamsTotal }} tracks</Trans>
{renderNotice(notices.specific['problematicStreams'])}
</td>
<td>
<HighlightedText style={{ cursor: 'pointer' }} onClick={onShowStreamsSelectorClick}><Trans>Keeping {{ numStreamsToCopy }} tracks</Trans></HighlightedText>
</td>
<td>
{renderNoticeIcon(notices.specific['problematicStreams'], rightIconStyle) ?? <HelpIcon onClick={onTracksHelpPress} />}
</td>
</tr>
<tr>
<td>
{t('Save output to path:')}
</td>
<td>
<span role="button" onClick={changeOutDir} style={outDirStyle}>{outputDir}</span>
</td>
<td />
</tr>
{canEditSegTemplate && (
<tr>
<td colSpan={2}>
<FileNameTemplateEditor template={outSegTemplate} setTemplate={setOutSegTemplate} defaultTemplate={defaultOutSegTemplate} generateFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
</td>
<td>
<HelpIcon onClick={onOutSegTemplateHelpPress} />
</td>
</tr>
)}
{willMerge && (
<tr>
<td colSpan={2}>
<FileNameTemplateEditor template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateMergedFileNames} mergeMode />
</td>
<td>
<HelpIcon onClick={onMergedFileTemplateHelpPress} />
</td>
</tr>
)}
<tr>
<td>
{t('Overwrite existing files')}
{renderNotice(notices.specific['overwriteOutput'])}
</td>
<td>
<Switch checked={enableOverwriteOutput} onCheckedChange={setEnableOverwriteOutput} />
</td>
<td>
{renderNoticeIcon(notices.specific['overwriteOutput'], rightIconStyle) ?? <HelpIcon onClick={() => showHelpText({ text: t('Overwrite files when exporting, if a file with the same name as the output file name exists?') })} />}
</td>
</tr>
</tbody>
</table>
<h3 style={{ marginBottom: '.5em' }}>{t('Advanced options')}</h3>
<table className={styles['options']}>
<tbody>
{areWeCutting && (
<>
<tr>
<td>
{t('Shift all start times')}
</td>
<td>
<ShiftTimes values={adjustCutFromValues} num={cutFromAdjustmentFrames} setNum={setCutFromAdjustmentFrames} />
</td>
<td>
<HelpIcon onClick={onCutFromAdjustmentFramesHelpPress} />
</td>
</tr>
<tr>
<td>
{t('Shift all end times')}
</td>
<td>
<ShiftTimes values={adjustCutToValues} num={cutToAdjustmentFrames} setNum={setCutToAdjustmentFrames} />
</td>
<td />
</tr>
</>
)}
{isMov && (
<>
<tr>
<td>
{t('Enable MOV Faststart?')}
</td>
<td>
<Switch checked={movFastStart} onCheckedChange={toggleMovFastStart} />
{renderNotice(notices.specific['movFastStart'])}
</td>
<td>
{renderNoticeIcon(notices.specific['movFastStart'], rightIconStyle) ?? <HelpIcon onClick={onMovFastStartHelpPress} />}
</td>
</tr>
<tr>
<td>
{t('Preserve all MP4/MOV metadata?')}
{renderNotice(notices.specific['preserveMovData'])}
</td>
<td>
<Switch checked={preserveMovData} onCheckedChange={togglePreserveMovData} />
</td>
<td>
{renderNoticeIcon(notices.specific['preserveMovData'], rightIconStyle) ?? <HelpIcon onClick={onPreserveMovDataHelpPress} />}
</td>
</tr>
</>
)}
<tr>
<td>
{t('Preserve chapters')}
</td>
<td>
<Switch checked={preserveChapters} onCheckedChange={togglePreserveChapters} />
</td>
<td>
<HelpIcon onClick={onPreserveChaptersPress} />
</td>
</tr>
<tr>
<td>
{t('Preserve metadata')}
</td>
<td>
<Select value={preserveMetadata} onChange={(e) => setPreserveMetadata(e.target.value as PreserveMetadata)} style={{ height: 20, marginLeft: 5 }}>
<option value={'default' as PreserveMetadata}>{t('Default')}</option>
<option value={'none' satisfies PreserveMetadata}>{t('None')}</option>
<option value={'nonglobal' satisfies PreserveMetadata}>{t('Non-global')}</option>
</Select>
</td>
<td>
<HelpIcon onClick={onPreserveMetadataHelpPress} />
</td>
</tr>
{willMerge && (
<>
<tr>
<td>
{t('Create chapters from merged segments? (slow)')}
</td>
<td>
<Switch checked={segmentsToChapters} onCheckedChange={toggleSegmentsToChapters} />
</td>
<td>
<HelpIcon onClick={onSegmentsToChaptersHelpPress} />
</td>
</tr>
<tr>
<td>
{t('Preserve original metadata when merging? (slow)')}
</td>
<td>
<Switch checked={preserveMetadataOnMerge} onCheckedChange={togglePreserveMetadataOnMerge} />
</td>
<td>
<HelpIcon onClick={onPreserveMetadataOnMergeHelpPress} />
</td>
</tr>
</>
)}
<tr>
<td style={{ paddingTop: '.5em', color: 'var(--gray-11)', fontSize: '.9em' }} colSpan={2}>
{t('Depending on your specific file/player, you may have to try different options for best results.')}
</td>
<td />
</tr>
{areWeCutting && (
<>
<tr>
<td>
{t('Smart cut (experimental):')}
{renderNotice(notices.specific['smartCut'])}
</td>
<td>
<Switch checked={enableSmartCut} onCheckedChange={() => setEnableSmartCut((v) => !v)} />
</td>
<td>
{renderNoticeIcon(notices.specific['smartCut'], rightIconStyle) ?? <HelpIcon onClick={onSmartCutHelpPress} />}
</td>
</tr>
{needSmartCut && (
<tr>
<td>
{t('Smart cut auto detect bitrate')}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{smartCutBitrate != null && (
<>
<TextInput value={smartCutBitrate} onChange={handleSmartCutBitrateChange} style={{ width: '4em', flexGrow: 0, marginRight: '.3em' }} />
<span style={{ marginRight: '.3em' }}>{t('kbit/s')}</span>
</>
)}
<span><Switch checked={smartCutBitrate == null} onCheckedChange={handleSmartCutBitrateToggle} /></span>
</div>
</td>
<td />
</tr>
)}
{!needSmartCut && (
<tr>
<td>
{t('Keyframe cut mode')}
{renderNotice(notices.specific['cutMode'])}
</td>
<td>
<Switch checked={keyframeCut} onCheckedChange={() => toggleKeyframeCut()} />
</td>
<td>
{renderNoticeIcon(notices.specific['cutMode'], rightIconStyle) ?? <HelpIcon onClick={onKeyframeCutHelpPress} />}
</td>
</tr>
)}
</>
)}
{!needSmartCut && (
<tr>
<td>
&quot;ffmpeg&quot; <code className="highlighted">avoid_negative_ts</code>
{renderNotice(notices.specific['avoidNegativeTs'])}
</td>
<td>
<Select value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value as AvoidNegativeTs)} style={{ height: 20, marginLeft: 5 }}>
<option value={'auto' as AvoidNegativeTs}>auto</option>
<option value={'make_zero' satisfies AvoidNegativeTs}>make_zero</option>
<option value={'make_non_negative' satisfies AvoidNegativeTs}>make_non_negative</option>
<option value={'disabled' satisfies AvoidNegativeTs}>disabled</option>
</Select>
</td>
<td>
{renderNoticeIcon(notices.specific['avoidNegativeTs'], rightIconStyle) ?? <HelpIcon onClick={onAvoidNegativeTsHelpPress} />}
</td>
</tr>
)}
<tr>
<td>
{t('"ffmpeg" experimental flag')}
</td>
<td>
<Switch checked={ffmpegExperimental} onCheckedChange={setFfmpegExperimental} />
</td>
<td>
<HelpIcon onClick={onFfmpegExperimentalHelpPress} />
</td>
</tr>
<tr>
<td>
{t('More settings')}
</td>
<td>
<IoIosSettings size={24} role="button" onClick={toggleSettings} style={{ marginLeft: 5 }} />
</td>
<td />
</tr>
</tbody>
</table>
</div>
</motion.div>
<div style={{ position: 'fixed', right: 0, bottom: 0, display: 'flex', alignItems: 'center', margin: 5 }}>
<motion.div
initial={{ opacity: 0, translateX: 50 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 50 }}
transition={{ duration: 0.4, easings: ['easeOut'] }}
style={{ display: 'flex', alignItems: 'flex-end', background: 'var(--gray-2)', borderRadius: '.5em', padding: '.3em' }}
>
<ToggleExportConfirm size="1.5em" />
<div style={{ fontSize: '.8em', marginLeft: '.4em', marginRight: '.5em', maxWidth: '8.5em', lineHeight: '100%', color: exportConfirmEnabled ? 'var(--gray-12)' : 'var(--gray-11)' }} role="button" onClick={toggleExportConfirmEnabled}>
{t('Show this page before exporting?')}
</div>
{notices.totalNum > 0 && (
renderNoticeIcon({ warning: true }, { fontSize: '1.5em', marginRight: '.5em' })
)}
</motion.div>
<motion.div
style={{ transformOrigin: 'bottom right' }}
initial={{ scale: 0.7, opacity: 1 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.7, opacity: 0 }}
transition={{ duration: 0.4, easings: ['easeOut'] }}
>
<ExportButton segmentsToExport={segmentsToExport} areWeCutting={areWeCutting} onClick={() => onExportConfirm()} size={1.7} />
</motion.div>
<ToggleExportConfirm size="1.5em" />
<div style={{ fontSize: '.8em', marginLeft: '.4em', marginRight: '.5em', maxWidth: '8.5em', lineHeight: '100%', color: exportConfirmEnabled ? 'var(--gray-12)' : 'var(--gray-11)' }} role="button" onClick={toggleExportConfirmEnabled}>
{t('Show this page before exporting?')}
</div>
{notices.totalNum > 0 && (
renderNoticeIcon({ warning: true }, { fontSize: '1.5em', marginRight: '.5em' })
)}
</>
)}
</AnimatePresence>
>
<table className={styles['options']}>
<tbody>
{notices.generic.map(({ warning, text }) => (
<tr key={text}>
<td colSpan={2}>
<div style={{ ...(warning ? { ...noticeStyle, color: 'var(--orange-8)' } : infoStyle), display: 'flex', alignItems: 'center', gap: '0 .5em' }}>
{renderNotice({ warning, text })}
</div>
</td>
<td />
</tr>
))}
{segmentsOrInverse.selected.length !== segmentsOrInverse.all.length && (
<tr>
<td colSpan={2}>
<FaRegCheckCircle size={12} style={{ marginRight: 3 }} />{t('{{selectedSegments}} of {{nonFilteredSegments}} segments selected', { selectedSegments: segmentsOrInverse.selected.length, nonFilteredSegments: segmentsOrInverse.all.length })}
</td>
<td />
</tr>
)}
<tr>
<td>
{segmentsOrInverse.selected.length > 1 ? t('Export mode for {{segments}} segments', { segments: segmentsOrInverse.selected.length }) : t('Export mode')}
{renderNotice(notices.specific['exportMode'])}
</td>
<td>
<ExportModeButton selectedSegments={segmentsOrInverse.selected} />
</td>
<td>
{renderNoticeIcon(notices.specific['exportMode'], rightIconStyle) ?? <HelpIcon onClick={onExportModeHelpPress} />}
</td>
</tr>
<tr>
<td>
{t('Output container format:')}
</td>
<td>
{renderOutFmt({ height: 20, maxWidth: 150 })}
</td>
<td>
<HelpIcon onClick={onOutFmtHelpPress} />
</td>
</tr>
<tr>
<td>
<Trans>Input has {{ numStreamsTotal }} tracks</Trans>
{renderNotice(notices.specific['problematicStreams'])}
</td>
<td>
<HighlightedText style={{ cursor: 'pointer' }} onClick={onShowStreamsSelectorClick}><Trans>Keeping {{ numStreamsToCopy }} tracks</Trans></HighlightedText>
</td>
<td>
{renderNoticeIcon(notices.specific['problematicStreams'], rightIconStyle) ?? <HelpIcon onClick={onTracksHelpPress} />}
</td>
</tr>
<tr>
<td>
{t('Save output to path:')}
</td>
<td>
<span role="button" onClick={changeOutDir} style={outDirStyle}>{outputDir}</span>
</td>
<td />
</tr>
{canEditSegTemplate && (
<tr>
<td colSpan={2}>
<FileNameTemplateEditor template={outSegTemplate} setTemplate={setOutSegTemplate} defaultTemplate={defaultOutSegTemplate} generateFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
</td>
<td>
<HelpIcon onClick={onOutSegTemplateHelpPress} />
</td>
</tr>
)}
{willMerge && (
<tr>
<td colSpan={2}>
<FileNameTemplateEditor template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateMergedFileNames} mergeMode />
</td>
<td>
<HelpIcon onClick={onMergedFileTemplateHelpPress} />
</td>
</tr>
)}
<tr>
<td>
{t('Overwrite existing files')}
{renderNotice(notices.specific['overwriteOutput'])}
</td>
<td>
<Switch checked={enableOverwriteOutput} onCheckedChange={setEnableOverwriteOutput} />
</td>
<td>
{renderNoticeIcon(notices.specific['overwriteOutput'], rightIconStyle) ?? <HelpIcon onClick={() => showHelpText({ text: t('Overwrite files when exporting, if a file with the same name as the output file name exists?') })} />}
</td>
</tr>
</tbody>
</table>
<h3 style={{ marginBottom: '.5em' }}>{t('Advanced options')}</h3>
<table className={styles['options']}>
<tbody>
{areWeCutting && (
<>
<tr>
<td>
{t('Shift all start times')}
</td>
<td>
<ShiftTimes values={adjustCutFromValues} num={cutFromAdjustmentFrames} setNum={setCutFromAdjustmentFrames} />
</td>
<td>
<HelpIcon onClick={onCutFromAdjustmentFramesHelpPress} />
</td>
</tr>
<tr>
<td>
{t('Shift all end times')}
</td>
<td>
<ShiftTimes values={adjustCutToValues} num={cutToAdjustmentFrames} setNum={setCutToAdjustmentFrames} />
</td>
<td />
</tr>
</>
)}
{isMov && (
<>
<tr>
<td>
{t('Enable MOV Faststart?')}
</td>
<td>
<Switch checked={movFastStart} onCheckedChange={toggleMovFastStart} />
{renderNotice(notices.specific['movFastStart'])}
</td>
<td>
{renderNoticeIcon(notices.specific['movFastStart'], rightIconStyle) ?? <HelpIcon onClick={onMovFastStartHelpPress} />}
</td>
</tr>
<tr>
<td>
{t('Preserve all MP4/MOV metadata?')}
{renderNotice(notices.specific['preserveMovData'])}
</td>
<td>
<Switch checked={preserveMovData} onCheckedChange={togglePreserveMovData} />
</td>
<td>
{renderNoticeIcon(notices.specific['preserveMovData'], rightIconStyle) ?? <HelpIcon onClick={onPreserveMovDataHelpPress} />}
</td>
</tr>
</>
)}
<tr>
<td>
{t('Preserve chapters')}
</td>
<td>
<Switch checked={preserveChapters} onCheckedChange={togglePreserveChapters} />
</td>
<td>
<HelpIcon onClick={onPreserveChaptersPress} />
</td>
</tr>
<tr>
<td>
{t('Preserve metadata')}
</td>
<td>
<Select value={preserveMetadata} onChange={(e) => setPreserveMetadata(e.target.value as PreserveMetadata)} style={{ height: 20, marginLeft: 5 }}>
<option value={'default' as PreserveMetadata}>{t('Default')}</option>
<option value={'none' satisfies PreserveMetadata}>{t('None')}</option>
<option value={'nonglobal' satisfies PreserveMetadata}>{t('Non-global')}</option>
</Select>
</td>
<td>
<HelpIcon onClick={onPreserveMetadataHelpPress} />
</td>
</tr>
{willMerge && (
<>
<tr>
<td>
{t('Create chapters from merged segments? (slow)')}
</td>
<td>
<Switch checked={segmentsToChapters} onCheckedChange={toggleSegmentsToChapters} />
</td>
<td>
<HelpIcon onClick={onSegmentsToChaptersHelpPress} />
</td>
</tr>
<tr>
<td>
{t('Preserve original metadata when merging? (slow)')}
</td>
<td>
<Switch checked={preserveMetadataOnMerge} onCheckedChange={togglePreserveMetadataOnMerge} />
</td>
<td>
<HelpIcon onClick={onPreserveMetadataOnMergeHelpPress} />
</td>
</tr>
</>
)}
<tr>
<td style={{ paddingTop: '.5em', color: 'var(--gray-11)', fontSize: '.9em' }} colSpan={2}>
{t('Depending on your specific file/player, you may have to try different options for best results.')}
</td>
<td />
</tr>
{areWeCutting && (
<>
<tr>
<td>
{t('Smart cut (experimental):')}
{renderNotice(notices.specific['smartCut'])}
</td>
<td>
<Switch checked={enableSmartCut} onCheckedChange={() => setEnableSmartCut((v) => !v)} />
</td>
<td>
{renderNoticeIcon(notices.specific['smartCut'], rightIconStyle) ?? <HelpIcon onClick={onSmartCutHelpPress} />}
</td>
</tr>
{needSmartCut && (
<tr>
<td>
{t('Smart cut auto detect bitrate')}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{smartCutBitrate != null && (
<>
<TextInput value={smartCutBitrate} onChange={handleSmartCutBitrateChange} style={{ width: '4em', flexGrow: 0, marginRight: '.3em' }} />
<span style={{ marginRight: '.3em' }}>{t('kbit/s')}</span>
</>
)}
<span><Switch checked={smartCutBitrate == null} onCheckedChange={handleSmartCutBitrateToggle} /></span>
</div>
</td>
<td />
</tr>
)}
{!needSmartCut && (
<tr>
<td>
{t('Keyframe cut mode')}
{renderNotice(notices.specific['cutMode'])}
</td>
<td>
<Switch checked={keyframeCut} onCheckedChange={() => toggleKeyframeCut()} />
</td>
<td>
{renderNoticeIcon(notices.specific['cutMode'], rightIconStyle) ?? <HelpIcon onClick={onKeyframeCutHelpPress} />}
</td>
</tr>
)}
</>
)}
{!needSmartCut && (
<tr>
<td>
&quot;ffmpeg&quot; <code className="highlighted">avoid_negative_ts</code>
{renderNotice(notices.specific['avoidNegativeTs'])}
</td>
<td>
<Select value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value as AvoidNegativeTs)} style={{ height: 20, marginLeft: 5 }}>
<option value={'auto' as AvoidNegativeTs}>auto</option>
<option value={'make_zero' satisfies AvoidNegativeTs}>make_zero</option>
<option value={'make_non_negative' satisfies AvoidNegativeTs}>make_non_negative</option>
<option value={'disabled' satisfies AvoidNegativeTs}>disabled</option>
</Select>
</td>
<td>
{renderNoticeIcon(notices.specific['avoidNegativeTs'], rightIconStyle) ?? <HelpIcon onClick={onAvoidNegativeTsHelpPress} />}
</td>
</tr>
)}
<tr>
<td>
{t('"ffmpeg" experimental flag')}
</td>
<td>
<Switch checked={ffmpegExperimental} onCheckedChange={setFfmpegExperimental} />
</td>
<td>
<HelpIcon onClick={onFfmpegExperimentalHelpPress} />
</td>
</tr>
<tr>
<td>
{t('More settings')}
</td>
<td>
<IoIosSettings size={24} role="button" onClick={toggleSettings} style={{ marginLeft: 5 }} />
</td>
<td />
</tr>
</tbody>
</table>
</ExportDialog>
);
}

@ -0,0 +1,32 @@
.sheet {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: var(--white-a11);
color: var(--gray-12);
backdrop-filter: blur(30px);
overflow-y: scroll;
display: flex;
justify-content: center;
align-items: flex-start;
}
:global(.dark-theme) .sheet {
background: var(--black-a11);
}
.box {
margin: 15px 15px 50px 15px;
border-radius: 10px;
padding: 10px 20px;
min-height: 500px;
position: relative;
max-width: 100%;
background: var(--white-a11);
}
:global(.dark-theme) .box {
background: var(--black-a11);
}

@ -0,0 +1,72 @@
import { CSSProperties, ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import styles from './ExportDialog.module.css';
import CloseButton from './CloseButton';
function ExportDialog({
visible,
children,
renderBottom,
renderButton,
onClosePress,
title,
width,
} : {
visible: boolean,
renderBottom?: (() => ReactNode | null) | undefined,
renderButton?: (() => ReactNode | null) | undefined,
children: ReactNode,
onClosePress: () => void,
title: string,
width: CSSProperties['width'],
}) {
// https://stackoverflow.com/questions/33454533/cant-scroll-to-top-of-flex-item-that-is-overflowing-container
return (
<AnimatePresence>
{visible && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className={styles['sheet']}
transition={{ duration: 0.3, easings: ['easeOut'] }}
>
<div className={styles['box']} style={{ width }}>
<h1 style={{ textTransform: 'uppercase', fontSize: '1.4em', marginTop: 0, marginBottom: '.5em' }}>{title}</h1>
<CloseButton type="submit" style={{ top: 0, right: 0 }} onClick={onClosePress} />
{children}
</div>
</motion.div>
<div style={{ position: 'fixed', right: 0, bottom: 0, display: 'flex', alignItems: 'center', margin: 5 }}>
<motion.div
initial={{ opacity: 0, translateX: 50 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 50 }}
transition={{ duration: 0.4, easings: ['easeOut'] }}
style={{ display: 'flex', alignItems: 'flex-end', background: 'var(--gray-2)', borderRadius: '.5em', padding: '.3em' }}
>
{renderBottom?.()}
</motion.div>
<motion.div
style={{ transformOrigin: 'bottom right' }}
initial={{ scale: 0.7, opacity: 1 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.7, opacity: 0 }}
transition={{ duration: 0.4, easings: ['easeOut'] }}
>
{renderButton?.()}
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}
export default ExportDialog;

@ -113,3 +113,16 @@ input:focus-visible, select:focus-visible {
outline: 0.1em solid var(--gray-11);
outline-offset: 0;
}
@keyframes pulse {
0% { transform: scale(1) }
45% { transform: scale(1.03) }
50% { transform: scale(1.15) }
55% { transform: scale(1.03) }
100% { transform: scale(1) }
}
/* because some people have a hard time finding the button */
.export-animation {
animation: pulse 2s linear infinite
}

Loading…
Cancel
Save