mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-26 12:12:39 +01:00
improve bottom bar and colors #1016
This commit is contained in:
parent
b698b5f445
commit
7f5351a300
50
src/App.jsx
50
src/App.jsx
@ -33,8 +33,7 @@ import StreamsSelector from './StreamsSelector';
|
||||
import SegmentList from './SegmentList';
|
||||
import Settings from './Settings';
|
||||
import Timeline from './Timeline';
|
||||
import BottomMenu from './BottomMenu';
|
||||
import TimelineControls from './TimelineControls';
|
||||
import BottomBar from './BottomBar';
|
||||
import ExportConfirm from './ExportConfirm';
|
||||
import ValueTuner from './components/ValueTuner';
|
||||
import VolumeControl from './components/VolumeControl';
|
||||
@ -2439,7 +2438,27 @@ const App = memo(() => {
|
||||
goToTimecode={goToTimecode}
|
||||
/>
|
||||
|
||||
<TimelineControls
|
||||
<BottomBar
|
||||
zoom={zoom}
|
||||
setZoom={setZoom}
|
||||
invertCutSegments={invertCutSegments}
|
||||
setInvertCutSegments={setInvertCutSegments}
|
||||
toggleComfortZoom={toggleComfortZoom}
|
||||
simpleMode={simpleMode}
|
||||
toggleSimpleMode={toggleSimpleMode}
|
||||
hasVideo={hasVideo}
|
||||
isRotationSet={isRotationSet}
|
||||
rotation={rotation}
|
||||
areWeCutting={areWeCutting}
|
||||
autoMerge={autoMerge}
|
||||
increaseRotation={increaseRotation}
|
||||
cleanupFiles={cleanupFiles}
|
||||
renderCaptureFormatButton={renderCaptureFormatButton}
|
||||
capture={capture}
|
||||
onExportPress={onExportPress}
|
||||
enabledOutSegments={enabledOutSegments}
|
||||
exportConfirmEnabled={exportConfirmEnabled}
|
||||
toggleExportConfirmEnabled={toggleExportConfirmEnabled}
|
||||
seekAbs={seekAbs}
|
||||
currentSegIndexSafe={currentSegIndexSafe}
|
||||
cutSegments={cutSegments}
|
||||
@ -2464,33 +2483,8 @@ const App = memo(() => {
|
||||
setTimelineMode={setTimelineMode}
|
||||
timelineMode={timelineMode}
|
||||
hasAudio={hasAudio}
|
||||
hasVideo={hasVideo}
|
||||
keyframesEnabled={keyframesEnabled}
|
||||
toggleKeyframesEnabled={toggleKeyframesEnabled}
|
||||
simpleMode={simpleMode}
|
||||
/>
|
||||
|
||||
<BottomMenu
|
||||
zoom={zoom}
|
||||
setZoom={setZoom}
|
||||
invertCutSegments={invertCutSegments}
|
||||
setInvertCutSegments={setInvertCutSegments}
|
||||
toggleComfortZoom={toggleComfortZoom}
|
||||
simpleMode={simpleMode}
|
||||
toggleSimpleMode={toggleSimpleMode}
|
||||
hasVideo={hasVideo}
|
||||
isRotationSet={isRotationSet}
|
||||
rotation={rotation}
|
||||
areWeCutting={areWeCutting}
|
||||
autoMerge={autoMerge}
|
||||
increaseRotation={increaseRotation}
|
||||
cleanupFiles={cleanupFiles}
|
||||
renderCaptureFormatButton={renderCaptureFormatButton}
|
||||
capture={capture}
|
||||
onExportPress={onExportPress}
|
||||
enabledOutSegments={enabledOutSegments}
|
||||
exportConfirmEnabled={exportConfirmEnabled}
|
||||
toggleExportConfirmEnabled={toggleExportConfirmEnabled}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
|
354
src/BottomBar.jsx
Normal file
354
src/BottomBar.jsx
Normal file
@ -0,0 +1,354 @@
|
||||
import React, { memo, useCallback, useEffect } from 'react';
|
||||
import { Select } from 'evergreen-ui';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MdRotate90DegreesCcw } from 'react-icons/md';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IoIosCamera, IoMdKey } from 'react-icons/io';
|
||||
import { FaYinYang, FaTrashAlt, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey } from 'react-icons/fa';
|
||||
import { GiSoundWaves } from 'react-icons/gi';
|
||||
// import useTraceUpdate from 'use-trace-update';
|
||||
|
||||
import { primaryTextColor, primaryColor } from './colors';
|
||||
import SegmentCutpointButton from './components/SegmentCutpointButton';
|
||||
import SetCutpointButton from './components/SetCutpointButton';
|
||||
import ExportButton from './components/ExportButton';
|
||||
import ToggleExportConfirm from './components/ToggleExportConfirm';
|
||||
|
||||
import SimpleModeButton from './components/SimpleModeButton';
|
||||
import { withBlur, toast, mirrorTransform } from './util';
|
||||
import { getSegColor } from './util/colors';
|
||||
import { formatDuration, parseDuration } from './util/duration';
|
||||
|
||||
const isDev = window.require('electron-is-dev');
|
||||
|
||||
const start = new Date().getTime();
|
||||
const zoomOptions = Array(13).fill().map((unused, z) => 2 ** z);
|
||||
|
||||
const leftRightWidth = 100;
|
||||
|
||||
const BottomBar = memo(({
|
||||
zoom, setZoom, invertCutSegments, setInvertCutSegments, toggleComfortZoom, simpleMode, toggleSimpleMode,
|
||||
isRotationSet, rotation, areWeCutting, increaseRotation, cleanupFiles, renderCaptureFormatButton,
|
||||
capture, onExportPress, enabledOutSegments, hasVideo, autoMerge, exportConfirmEnabled, toggleExportConfirmEnabled,
|
||||
seekAbs, currentSegIndexSafe, cutSegments, currentCutSeg, setCutStart, setCutEnd,
|
||||
setCurrentSegIndex, cutStartTimeManual, setCutStartTimeManual, cutEndTimeManual, setCutEndTimeManual,
|
||||
duration, jumpCutEnd, jumpCutStart, startTimeOffset, setCutTime, currentApparentCutSeg,
|
||||
playing, shortStep, togglePlay, setTimelineMode, hasAudio, timelineMode,
|
||||
keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onYinYangClick = useCallback(() => {
|
||||
setInvertCutSegments(v => {
|
||||
const newVal = !v;
|
||||
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;
|
||||
});
|
||||
}, [setInvertCutSegments, t]);
|
||||
|
||||
const rotationStr = `${rotation}°`;
|
||||
|
||||
// Clear manual overrides if upstream cut time has changed
|
||||
useEffect(() => {
|
||||
setCutStartTimeManual();
|
||||
setCutEndTimeManual();
|
||||
}, [setCutStartTimeManual, setCutEndTimeManual, currentApparentCutSeg.start, currentApparentCutSeg.end]);
|
||||
|
||||
function renderJumpCutpointButton(direction) {
|
||||
const newIndex = currentSegIndexSafe + direction;
|
||||
const seg = cutSegments[newIndex];
|
||||
|
||||
const backgroundColor = seg && getSegColor(seg).alpha(0.5).string();
|
||||
const opacity = seg ? undefined : 0.3;
|
||||
const segButtonStyle = {
|
||||
backgroundColor, opacity, padding: 6, borderRadius: 10, color: 'white', fontSize: 14, width: 7, lineHeight: '11px', fontWeight: 'bold', margin: '0 5px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={segButtonStyle}
|
||||
role="button"
|
||||
title={`${direction > 0 ? t('Select next segment') : t('Select previous segment')} (${newIndex + 1})`}
|
||||
onClick={() => seg && setCurrentSegIndex(newIndex)}
|
||||
>
|
||||
{seg ? newIndex + 1 : '-'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCutTimeInput(type) {
|
||||
const isStart = type === 'start';
|
||||
|
||||
const cutTimeManual = isStart ? cutStartTimeManual : cutEndTimeManual;
|
||||
const cutTime = isStart ? currentApparentCutSeg.start : currentApparentCutSeg.end;
|
||||
const setCutTimeManual = isStart ? setCutStartTimeManual : setCutEndTimeManual;
|
||||
|
||||
const isCutTimeManualSet = () => cutTimeManual !== undefined;
|
||||
|
||||
const border = `1px solid ${getSegColor(currentCutSeg).desaturate(0.3).string()}`;
|
||||
|
||||
const cutTimeInputStyle = {
|
||||
background: 'white', border, borderRadius: 5, color: 'rgba(0, 0, 0, 0.7)', fontSize: 13, textAlign: 'center', padding: '1px 5px', marginTop: 0, marginBottom: 0, marginLeft: isStart ? 0 : 5, marginRight: isStart ? 5 : 0, boxSizing: 'border-box', fontFamily: 'inherit', width: 90, outline: 'none',
|
||||
};
|
||||
|
||||
function parseAndSetCutTime(text) {
|
||||
setCutTimeManual(text);
|
||||
|
||||
// Don't proceed if not a valid time value
|
||||
const timeWithOffset = parseDuration(text);
|
||||
if (timeWithOffset === undefined) return;
|
||||
|
||||
const timeWithoutOffset = Math.max(timeWithOffset - startTimeOffset, 0);
|
||||
try {
|
||||
setCutTime(type, timeWithoutOffset);
|
||||
seekAbs(timeWithoutOffset);
|
||||
} catch (err) {
|
||||
console.error('Cannot set cut time', err);
|
||||
// If we get an error from setCutTime, remain in the editing state (cutTimeManual)
|
||||
// https://github.com/mifi/lossless-cut/issues/988
|
||||
}
|
||||
}
|
||||
|
||||
function handleCutTimeInput(text) {
|
||||
// Allow the user to erase to reset
|
||||
if (text.length === 0) {
|
||||
setCutTimeManual();
|
||||
return;
|
||||
}
|
||||
|
||||
parseAndSetCutTime(text);
|
||||
}
|
||||
|
||||
async function handleCutTimePaste(e) {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const clipboardData = e.clipboardData.getData('Text');
|
||||
parseAndSetCutTime(clipboardData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
|
||||
type="text"
|
||||
title={isStart ? t('Manually input cut start point') : t('Manually input cut end point')}
|
||||
onChange={e => handleCutTimeInput(e.target.value)}
|
||||
onPaste={handleCutTimePaste}
|
||||
value={isCutTimeManualSet()
|
||||
? cutTimeManual
|
||||
: formatDuration({ seconds: cutTime + startTimeOffset })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PlayPause = playing ? FaPause : FaPlay;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexBasis: leftRightWidth }}>
|
||||
{hasAudio && !simpleMode && (
|
||||
<GiSoundWaves
|
||||
size={24}
|
||||
style={{ padding: '0 5px', color: timelineMode === 'waveform' ? primaryTextColor : undefined }}
|
||||
role="button"
|
||||
title={t('Show waveform')}
|
||||
onClick={() => setTimelineMode('waveform')}
|
||||
/>
|
||||
)}
|
||||
{hasVideo && !simpleMode && (
|
||||
<>
|
||||
<FaImages
|
||||
size={20}
|
||||
style={{ padding: '0 5px', color: timelineMode === 'thumbnails' ? primaryTextColor : undefined }}
|
||||
role="button"
|
||||
title={t('Show thumbnails')}
|
||||
onClick={() => setTimelineMode('thumbnails')}
|
||||
/>
|
||||
|
||||
<FaKey
|
||||
size={16}
|
||||
style={{ padding: '0 5px', color: keyframesEnabled ? primaryTextColor : undefined }}
|
||||
role="button"
|
||||
title={t('Show keyframes')}
|
||||
onClick={toggleKeyframesEnabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{!simpleMode && (
|
||||
<FaStepBackward
|
||||
size={16}
|
||||
title={t('Jump to start of video')}
|
||||
role="button"
|
||||
onClick={() => seekAbs(0)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!simpleMode && renderJumpCutpointButton(-1)}
|
||||
|
||||
{!simpleMode && <SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} onClick={jumpCutStart} title={t('Jump to cut start')} style={{ marginRight: 5 }} />}
|
||||
<SetCutpointButton currentCutSeg={currentCutSeg} side="start" onClick={setCutStart} title={t('Set cut start to current position')} style={{ marginRight: 5 }} />
|
||||
|
||||
{!simpleMode && renderCutTimeInput('start')}
|
||||
|
||||
<IoMdKey
|
||||
size={25}
|
||||
role="button"
|
||||
title={t('Seek previous keyframe')}
|
||||
style={{ flexShrink: 0, marginRight: 2, transform: mirrorTransform }}
|
||||
onClick={() => seekClosestKeyframe(-1)}
|
||||
/>
|
||||
|
||||
{!simpleMode && (
|
||||
<FaCaretLeft
|
||||
style={{ flexShrink: 0, marginLeft: -6, marginRight: -4 }}
|
||||
size={28}
|
||||
role="button"
|
||||
title={t('One frame back')}
|
||||
onClick={() => shortStep(-1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ background: primaryColor, margin: '2px 5px 0 5px', display: 'flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 17 }}>
|
||||
<PlayPause
|
||||
style={{ marginLeft: playing ? 0 : 2 }}
|
||||
size={16}
|
||||
role="button"
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!simpleMode && (
|
||||
<FaCaretRight
|
||||
style={{ flexShrink: 0, marginRight: -6, marginLeft: -4 }}
|
||||
size={28}
|
||||
role="button"
|
||||
title={t('One frame forward')}
|
||||
onClick={() => shortStep(1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IoMdKey
|
||||
style={{ flexShrink: 0, marginLeft: 2 }}
|
||||
size={25}
|
||||
role="button"
|
||||
title={t('Seek next keyframe')}
|
||||
onClick={() => seekClosestKeyframe(1)}
|
||||
/>
|
||||
|
||||
{!simpleMode && renderCutTimeInput('end')}
|
||||
|
||||
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={t('Set cut end to current position')} style={{ marginLeft: 5 }} />
|
||||
{!simpleMode && <SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} onClick={jumpCutEnd} title={t('Jump to cut end')} style={{ marginLeft: 5 }} />}
|
||||
|
||||
{!simpleMode && renderJumpCutpointButton(1)}
|
||||
|
||||
{!simpleMode && (
|
||||
<FaStepForward
|
||||
size={16}
|
||||
title={t('Jump to end of video')}
|
||||
role="button"
|
||||
onClick={() => seekAbs(duration)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<div style={{ flexBasis: leftRightWidth }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="no-user-select"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '3px 4px' }}
|
||||
>
|
||||
<SimpleModeButton simpleMode={simpleMode} toggleSimpleMode={toggleSimpleMode} style={{ flexShrink: 0 }} />
|
||||
|
||||
{simpleMode && <div role="button" onClick={toggleSimpleMode} style={{ marginLeft: 5, fontSize: '90%' }}>{t('Toggle advanced view')}</div>}
|
||||
|
||||
{!simpleMode && (
|
||||
<div style={{ marginLeft: 5 }}>
|
||||
<motion.div
|
||||
style={{ width: 24, height: 24 }}
|
||||
animate={{ rotateX: invertCutSegments ? 0 : 180 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<FaYinYang
|
||||
size={24}
|
||||
role="button"
|
||||
title={invertCutSegments ? t('Discard selected segments') : t('Keep selected segments')}
|
||||
onClick={onYinYangClick}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div role="button" style={{ marginRight: 5, marginLeft: 10 }} title={t('Zoom')} onClick={toggleComfortZoom}>{Math.floor(zoom)}x</div>
|
||||
|
||||
<Select height={20} style={{ flexBasis: 85, flexGrow: 0 }} value={zoomOptions.includes(zoom) ? zoom.toString() : ''} title={t('Zoom')} onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
|
||||
<option key="" value="" disabled>{t('Zoom')}</option>
|
||||
{zoomOptions.map(val => (
|
||||
<option key={val} value={String(val)}>{t('Zoom')} {val}x</option>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ color: 'rgba(255,255,255,0.3)', flexShrink: 1, flexGrow: 0, overflow: 'hidden', margin: '0 10px' }}>{!isDev && new Date().getTime() - start > 2 * 60 * 1000 && ['t', 'u', 'C', 's', 's', 'e', 'l', 's', 's', 'o', 'L'].reverse().join('')}</div>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{hasVideo && (
|
||||
<>
|
||||
<span style={{ textAlign: 'right', display: 'inline-block' }}>{isRotationSet && rotationStr}</span>
|
||||
<MdRotate90DegreesCcw
|
||||
size={24}
|
||||
style={{ margin: '0px 0px 0 2px', verticalAlign: 'middle', color: isRotationSet ? primaryTextColor : undefined }}
|
||||
title={`${t('Set output rotation. Current: ')} ${isRotationSet ? rotationStr : t('Don\'t modify')}`}
|
||||
onClick={increaseRotation}
|
||||
role="button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<FaTrashAlt
|
||||
title={t('Close file and clean up')}
|
||||
style={{ padding: '5px 10px' }}
|
||||
size={16}
|
||||
onClick={cleanupFiles}
|
||||
role="button"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasVideo && (
|
||||
<>
|
||||
{!simpleMode && renderCaptureFormatButton({ height: 20 })}
|
||||
|
||||
<IoIosCamera
|
||||
style={{ paddingLeft: 5, paddingRight: 15 }}
|
||||
size={25}
|
||||
title={t('Capture frame')}
|
||||
onClick={capture}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!simpleMode && <ToggleExportConfirm style={{ marginRight: 5 }} exportConfirmEnabled={exportConfirmEnabled} toggleExportConfirmEnabled={toggleExportConfirmEnabled} />}
|
||||
|
||||
<ExportButton enabledOutSegments={enabledOutSegments} areWeCutting={areWeCutting} autoMerge={autoMerge} onClick={onExportPress} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default BottomBar;
|
@ -1,126 +0,0 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Select } from 'evergreen-ui';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaYinYang, FaTrashAlt } from 'react-icons/fa';
|
||||
import { MdRotate90DegreesCcw } from 'react-icons/md';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IoIosCamera } from 'react-icons/io';
|
||||
|
||||
import { primaryTextColor } from './colors';
|
||||
|
||||
import ExportButton from './components/ExportButton';
|
||||
import ToggleExportConfirm from './components/ToggleExportConfirm';
|
||||
|
||||
|
||||
import SimpleModeButton from './components/SimpleModeButton';
|
||||
import { withBlur, toast } from './util';
|
||||
|
||||
const isDev = window.require('electron-is-dev');
|
||||
const start = new Date().getTime();
|
||||
const zoomOptions = Array(13).fill().map((unused, z) => 2 ** z);
|
||||
|
||||
const BottomMenu = memo(({
|
||||
zoom, setZoom, invertCutSegments, setInvertCutSegments, toggleComfortZoom, simpleMode, toggleSimpleMode,
|
||||
isRotationSet, rotation, areWeCutting, increaseRotation, cleanupFiles, renderCaptureFormatButton,
|
||||
capture, onExportPress, enabledOutSegments, hasVideo, autoMerge, exportConfirmEnabled, toggleExportConfirmEnabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onYinYangClick = useCallback(() => {
|
||||
setInvertCutSegments(v => {
|
||||
const newVal = !v;
|
||||
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;
|
||||
});
|
||||
}, [setInvertCutSegments, t]);
|
||||
|
||||
const rotationStr = `${rotation}°`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="no-user-select"
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '3px 4px' }}
|
||||
>
|
||||
<SimpleModeButton simpleMode={simpleMode} toggleSimpleMode={toggleSimpleMode} style={{ flexShrink: 0 }} />
|
||||
|
||||
{simpleMode && <div role="button" onClick={toggleSimpleMode} style={{ marginLeft: 5, fontSize: '90%' }}>{t('Toggle advanced view')}</div>}
|
||||
|
||||
{!simpleMode && (
|
||||
<div style={{ marginLeft: 5 }}>
|
||||
<motion.div
|
||||
style={{ width: 24, height: 24 }}
|
||||
animate={{ rotateX: invertCutSegments ? 0 : 180 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<FaYinYang
|
||||
size={24}
|
||||
role="button"
|
||||
title={invertCutSegments ? t('Discard selected segments') : t('Keep selected segments')}
|
||||
onClick={onYinYangClick}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<>
|
||||
<div role="button" style={{ marginRight: 5, marginLeft: 10 }} title={t('Zoom')} onClick={toggleComfortZoom}>{Math.floor(zoom)}x</div>
|
||||
|
||||
<Select height={20} style={{ flexBasis: 85, flexGrow: 0 }} value={zoomOptions.includes(zoom) ? zoom.toString() : ''} title={t('Zoom')} onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
|
||||
<option key="" value="" disabled>{t('Zoom')}</option>
|
||||
{zoomOptions.map(val => (
|
||||
<option key={val} value={String(val)}>{t('Zoom')} {val}x</option>
|
||||
))}
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ color: 'rgba(255,255,255,0.3)', flexShrink: 1, flexGrow: 0, overflow: 'hidden', margin: '0 10px' }}>{!isDev && new Date().getTime() - start > 2 * 60 * 1000 && ['t', 'u', 'C', 's', 's', 'e', 'l', 's', 's', 'o', 'L'].reverse().join('')}</div>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{hasVideo && (
|
||||
<>
|
||||
<span style={{ textAlign: 'right', display: 'inline-block' }}>{isRotationSet && rotationStr}</span>
|
||||
<MdRotate90DegreesCcw
|
||||
size={24}
|
||||
style={{ margin: '0px 0px 0 2px', verticalAlign: 'middle', color: isRotationSet ? primaryTextColor : undefined }}
|
||||
title={`${t('Set output rotation. Current: ')} ${isRotationSet ? rotationStr : t('Don\'t modify')}`}
|
||||
onClick={increaseRotation}
|
||||
role="button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!simpleMode && (
|
||||
<FaTrashAlt
|
||||
title={t('Close file and clean up')}
|
||||
style={{ padding: '5px 10px' }}
|
||||
size={16}
|
||||
onClick={cleanupFiles}
|
||||
role="button"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasVideo && (
|
||||
<>
|
||||
{!simpleMode && renderCaptureFormatButton({ height: 20 })}
|
||||
|
||||
<IoIosCamera
|
||||
style={{ paddingLeft: 5, paddingRight: 15 }}
|
||||
size={25}
|
||||
title={t('Capture frame')}
|
||||
onClick={capture}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!simpleMode && <ToggleExportConfirm style={{ marginRight: 5 }} exportConfirmEnabled={exportConfirmEnabled} toggleExportConfirmEnabled={toggleExportConfirmEnabled} />}
|
||||
|
||||
<ExportButton enabledOutSegments={enabledOutSegments} areWeCutting={areWeCutting} autoMerge={autoMerge} onClick={onExportPress} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default BottomMenu;
|
@ -11,7 +11,7 @@ import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
|
||||
import useContextMenu from './hooks/useContextMenu';
|
||||
import { saveColor } from './colors';
|
||||
import { getSegColors } from './util/colors';
|
||||
import { getSegColor } from './util/colors';
|
||||
|
||||
const buttonBaseStyle = {
|
||||
margin: '0 3px', borderRadius: 3, color: 'white', cursor: 'pointer',
|
||||
@ -70,9 +70,9 @@ const Segment = memo(({ seg, index, currentSegIndex, formatTimecode, getFrameCou
|
||||
function renderNumber() {
|
||||
if (invertCutSegments) return <FaSave style={{ color: saveColor, marginRight: 5, verticalAlign: 'middle' }} size={14} />;
|
||||
|
||||
const { segBgColor, segBorderColor } = getSegColors(seg);
|
||||
const segColor = getSegColor(seg);
|
||||
|
||||
return <b style={{ color: 'white', padding: '0 4px', marginRight: 3, background: segBgColor, border: `1px solid ${isActive ? segBorderColor : 'transparent'}`, borderRadius: 10, fontSize: 12 }}>{index + 1}</b>;
|
||||
return <b style={{ color: 'white', padding: '0 4px', marginRight: 3, background: segColor.alpha(0.5).string(), border: `1px solid ${isActive ? segColor.lighten(0.5).string() : 'transparent'}`, borderRadius: 10, fontSize: 12 }}>{index + 1}</b>;
|
||||
}
|
||||
|
||||
const timeStr = useMemo(() => `${formatTimecode({ seconds: seg.start })} - ${formatTimecode({ seconds: seg.end })}`, [seg.start, seg.end, formatTimecode]);
|
||||
@ -166,14 +166,14 @@ const SegmentList = memo(({
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
const { segActiveBgColor: currentSegActiveBgColor } = getSegColors(currentCutSeg);
|
||||
const { segActiveBgColor: segmentAtCursorActiveBgColor } = getSegColors(segmentAtCursor);
|
||||
const currentSegColor = getSegColor(currentCutSeg).alpha(0.5).desaturate(0.4).string();
|
||||
const segAtCursorColor = getSegColor(segmentAtCursor).desaturate(0.4).alpha(0.5).string();
|
||||
|
||||
function renderExportEnabledCheckBox() {
|
||||
const segmentExportEnabled = currentCutSeg && enabledOutSegmentsRaw.some((s) => s.segId === currentCutSeg.segId);
|
||||
const Icon = segmentExportEnabled ? FaCheck : FaTimes;
|
||||
|
||||
return <Icon size={24} title={segmentExportEnabled ? t('Include this segment in export') : t('Exclude this segment from export')} style={{ ...buttonBaseStyle, backgroundColor: currentSegActiveBgColor }} role="button" onClick={() => onExportSegmentEnabledToggle(currentCutSeg)} />;
|
||||
return <Icon size={24} title={segmentExportEnabled ? t('Include this segment in export') : t('Exclude this segment from export')} style={{ ...buttonBaseStyle, backgroundColor: currentSegColor }} role="button" onClick={() => onExportSegmentEnabledToggle(currentCutSeg)} />;
|
||||
}
|
||||
|
||||
const segmentsTotal = enabledOutSegments.reduce((acc, { start, end }) => (end - start) + acc, 0);
|
||||
@ -191,7 +191,7 @@ const SegmentList = memo(({
|
||||
|
||||
<FaMinus
|
||||
size={24}
|
||||
style={{ ...buttonBaseStyle, background: cutSegments.length >= 2 ? currentSegActiveBgColor : neutralButtonColor }}
|
||||
style={{ ...buttonBaseStyle, background: cutSegments.length >= 2 ? currentSegColor : neutralButtonColor }}
|
||||
role="button"
|
||||
title={`${t('Remove segment')} ${currentSegIndex + 1}`}
|
||||
onClick={() => removeCutSegment(currentSegIndex)}
|
||||
@ -203,7 +203,7 @@ const SegmentList = memo(({
|
||||
size={16}
|
||||
title={t('Change segment order')}
|
||||
role="button"
|
||||
style={{ ...buttonBaseStyle, padding: 4, background: currentSegActiveBgColor }}
|
||||
style={{ ...buttonBaseStyle, padding: 4, background: currentSegColor }}
|
||||
onClick={() => onReorderSegsPress(currentSegIndex)}
|
||||
/>
|
||||
|
||||
@ -211,7 +211,7 @@ const SegmentList = memo(({
|
||||
size={16}
|
||||
title={t('Label segment')}
|
||||
role="button"
|
||||
style={{ ...buttonBaseStyle, padding: 4, background: currentSegActiveBgColor }}
|
||||
style={{ ...buttonBaseStyle, padding: 4, background: currentSegColor }}
|
||||
onClick={() => onLabelSegmentPress(currentSegIndex)}
|
||||
/>
|
||||
|
||||
@ -220,10 +220,10 @@ const SegmentList = memo(({
|
||||
)}
|
||||
|
||||
<AiOutlineSplitCells
|
||||
size={16}
|
||||
size={22}
|
||||
title={t('Split segment at cursor')}
|
||||
role="button"
|
||||
style={{ ...buttonBaseStyle, padding: 4, background: segmentAtCursor ? segmentAtCursorActiveBgColor : neutralButtonColor }}
|
||||
style={{ ...buttonBaseStyle, padding: 1, background: segmentAtCursor ? segAtCursorColor : neutralButtonColor }}
|
||||
onClick={splitCurrentSegment}
|
||||
/>
|
||||
</div>
|
||||
|
@ -12,7 +12,7 @@ import useContextMenu from './hooks/useContextMenu';
|
||||
|
||||
import { timelineBackground } from './colors';
|
||||
|
||||
import { getSegColors } from './util/colors';
|
||||
import { getSegColor } from './util/colors';
|
||||
|
||||
const currentTimeWidth = 1;
|
||||
|
||||
@ -259,7 +259,7 @@ const Timeline = memo(({
|
||||
)}
|
||||
|
||||
{apparentCutSegments.map((seg, i) => {
|
||||
const { segBgColor, segActiveBgColor, segBorderColor } = getSegColors(seg);
|
||||
const segColor = getSegColor(seg);
|
||||
|
||||
if (seg.start === 0 && seg.end === 0) return null; // No video loaded
|
||||
|
||||
@ -267,9 +267,9 @@ const Timeline = memo(({
|
||||
<TimelineSeg
|
||||
key={seg.segId}
|
||||
segNum={i}
|
||||
segBgColor={segBgColor}
|
||||
segActiveBgColor={segActiveBgColor}
|
||||
segBorderColor={segBorderColor}
|
||||
segBgColor={segColor.alpha(0.5).string()}
|
||||
segActiveBgColor={segColor.lighten(0.5).alpha(0.5).string()}
|
||||
segBorderColor={segColor.lighten(0.5).string()}
|
||||
onSegClick={setCurrentSegIndex}
|
||||
isActive={i === currentSegIndexSafe}
|
||||
duration={durationSafe}
|
||||
|
@ -1,244 +0,0 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import { 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 } from './util/colors';
|
||||
import { formatDuration, parseDuration } from './util/duration';
|
||||
import { primaryTextColor } from './colors';
|
||||
import SegmentCutpointButton from './components/SegmentCutpointButton';
|
||||
import SetCutpointButton from './components/SetCutpointButton';
|
||||
import { mirrorTransform } from './util';
|
||||
|
||||
const TimelineControls = memo(({
|
||||
seekAbs, currentSegIndexSafe, cutSegments, currentCutSeg, setCutStart, setCutEnd,
|
||||
setCurrentSegIndex, cutStartTimeManual, setCutStartTimeManual, cutEndTimeManual, setCutEndTimeManual,
|
||||
duration, jumpCutEnd, jumpCutStart, startTimeOffset, setCutTime, currentApparentCutSeg,
|
||||
playing, shortStep, togglePlay, setTimelineMode, hasAudio, hasVideo, timelineMode,
|
||||
keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe, simpleMode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Clear manual overrides if upstream cut time has changed
|
||||
useEffect(() => {
|
||||
setCutStartTimeManual();
|
||||
setCutEndTimeManual();
|
||||
}, [setCutStartTimeManual, setCutEndTimeManual, currentApparentCutSeg.start, currentApparentCutSeg.end]);
|
||||
|
||||
function renderJumpCutpointButton(direction) {
|
||||
const newIndex = currentSegIndexSafe + direction;
|
||||
const seg = cutSegments[newIndex];
|
||||
|
||||
const getSegButtonStyle = ({ segActiveBgColor, segBorderColor }) => ({ background: segActiveBgColor, border: `2px solid ${segBorderColor}`, borderRadius: 6, color: 'white', fontSize: 14, textAlign: 'center', lineHeight: '11px', fontWeight: 'bold' });
|
||||
|
||||
let segButtonStyle;
|
||||
|
||||
if (seg) {
|
||||
const { segActiveBgColor, segBorderColor } = getSegColors(seg);
|
||||
segButtonStyle = getSegButtonStyle({ segActiveBgColor, segBorderColor });
|
||||
} else {
|
||||
segButtonStyle = getSegButtonStyle({ segActiveBgColor: 'rgba(255,255,255,0.3)', segBorderColor: 'rgba(255,255,255,0.5)' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ ...segButtonStyle, height: 10, padding: 4, margin: '0 5px' }}
|
||||
role="button"
|
||||
title={`${direction > 0 ? t('Select next segment') : t('Select previous segment')} (${newIndex + 1})`}
|
||||
onClick={() => seg && setCurrentSegIndex(newIndex)}
|
||||
>
|
||||
{newIndex + 1}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCutTimeInput(type) {
|
||||
const isStart = type === 'start';
|
||||
|
||||
const cutTimeManual = isStart ? cutStartTimeManual : cutEndTimeManual;
|
||||
const cutTime = isStart ? currentApparentCutSeg.start : currentApparentCutSeg.end;
|
||||
const setCutTimeManual = isStart ? setCutStartTimeManual : setCutEndTimeManual;
|
||||
|
||||
const isCutTimeManualSet = () => cutTimeManual !== undefined;
|
||||
|
||||
const cutTimeInputStyle = {
|
||||
background: 'white', borderRadius: 5, color: 'rgba(0, 0, 0, 0.7)', fontSize: 13, textAlign: 'center', padding: '1px 5px', marginTop: 0, marginBottom: 0, marginLeft: isStart ? 0 : 5, marginRight: isStart ? 5 : 0, border: 'none', boxSizing: 'border-box', fontFamily: 'inherit', width: 90, outline: 'none',
|
||||
};
|
||||
|
||||
function parseAndSetCutTime(text) {
|
||||
setCutTimeManual(text);
|
||||
|
||||
// Don't proceed if not a valid time value
|
||||
const timeWithOffset = parseDuration(text);
|
||||
if (timeWithOffset === undefined) return;
|
||||
|
||||
const timeWithoutOffset = Math.max(timeWithOffset - startTimeOffset, 0);
|
||||
try {
|
||||
setCutTime(type, timeWithoutOffset);
|
||||
seekAbs(timeWithoutOffset);
|
||||
} catch (err) {
|
||||
console.error('Cannot set cut time', err);
|
||||
// If we get an error from setCutTime, remain in the editing state (cutTimeManual)
|
||||
// https://github.com/mifi/lossless-cut/issues/988
|
||||
}
|
||||
}
|
||||
|
||||
function handleCutTimeInput(text) {
|
||||
// Allow the user to erase to reset
|
||||
if (text.length === 0) {
|
||||
setCutTimeManual();
|
||||
return;
|
||||
}
|
||||
|
||||
parseAndSetCutTime(text);
|
||||
}
|
||||
|
||||
async function handleCutTimePaste(e) {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const clipboardData = e.clipboardData.getData('Text');
|
||||
parseAndSetCutTime(clipboardData);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
|
||||
type="text"
|
||||
title={isStart ? t('Manually input cut start point') : t('Manually input cut end point')}
|
||||
onChange={e => handleCutTimeInput(e.target.value)}
|
||||
onPaste={handleCutTimePaste}
|
||||
value={isCutTimeManualSet()
|
||||
? cutTimeManual
|
||||
: formatDuration({ seconds: cutTime + startTimeOffset })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const PlayPause = playing ? FaPause : FaPlay;
|
||||
|
||||
const leftRightWidth = 100;
|
||||
const toolbarHeight = 24;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: toolbarHeight }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexBasis: leftRightWidth }}>
|
||||
{hasAudio && !simpleMode && (
|
||||
<GiSoundWaves
|
||||
size={24}
|
||||
style={{ padding: '0 5px', color: timelineMode === 'waveform' ? primaryTextColor : undefined }}
|
||||
role="button"
|
||||
title={t('Show waveform')}
|
||||
onClick={() => setTimelineMode('waveform')}
|
||||
/>
|
||||
)}
|
||||
{hasVideo && !simpleMode && (
|
||||
<>
|
||||
<FaImages
|
||||
size={20}
|
||||
style={{ padding: '0 5px', color: timelineMode === 'thumbnails' ? primaryTextColor : undefined }}
|
||||
role="button"
|
||||
title={t('Show thumbnails')}
|
||||
onClick={() => setTimelineMode('thumbnails')}
|
||||
/>
|
||||
|
||||
<FaKey
|
||||
size={16}
|
||||
style={{ padding: '0 5px', color: keyframesEnabled ? primaryTextColor : undefined }}
|
||||
role="button"
|
||||
title={t('Show keyframes')}
|
||||
onClick={toggleKeyframesEnabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{!simpleMode && (
|
||||
<FaStepBackward
|
||||
size={16}
|
||||
title={t('Jump to start of video')}
|
||||
role="button"
|
||||
onClick={() => seekAbs(0)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!simpleMode && renderJumpCutpointButton(-1)}
|
||||
|
||||
{!simpleMode && <SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} onClick={jumpCutStart} title={t('Jump to cut start')} style={{ marginRight: 5 }} />}
|
||||
<SetCutpointButton currentCutSeg={currentCutSeg} side="start" onClick={setCutStart} title={t('Set cut start to current position')} style={{ marginRight: 5 }} />
|
||||
|
||||
{!simpleMode && renderCutTimeInput('start')}
|
||||
|
||||
<IoMdKey
|
||||
size={20}
|
||||
role="button"
|
||||
title={t('Seek previous keyframe')}
|
||||
style={{ marginRight: 5, transform: mirrorTransform }}
|
||||
onClick={() => seekClosestKeyframe(-1)}
|
||||
/>
|
||||
|
||||
{!simpleMode && (
|
||||
<FaCaretLeft
|
||||
style={{ marginLeft: -5 }}
|
||||
size={20}
|
||||
role="button"
|
||||
title={t('One frame back')}
|
||||
onClick={() => shortStep(-1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlayPause
|
||||
size={16}
|
||||
role="button"
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{!simpleMode && (
|
||||
<FaCaretRight
|
||||
style={{ marginRight: -5, marginLeft: -2 }}
|
||||
size={20}
|
||||
role="button"
|
||||
title={t('One frame forward')}
|
||||
onClick={() => shortStep(1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IoMdKey
|
||||
style={{ marginLeft: 5 }}
|
||||
size={20}
|
||||
role="button"
|
||||
title={t('Seek next keyframe')}
|
||||
onClick={() => seekClosestKeyframe(1)}
|
||||
/>
|
||||
|
||||
{!simpleMode && renderCutTimeInput('end')}
|
||||
|
||||
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={t('Set cut end to current position')} style={{ marginLeft: 5 }} />
|
||||
{!simpleMode && <SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} onClick={jumpCutEnd} title={t('Jump to cut end')} style={{ marginLeft: 5 }} />}
|
||||
|
||||
{!simpleMode && renderJumpCutpointButton(1)}
|
||||
|
||||
{!simpleMode && (
|
||||
<FaStepForward
|
||||
size={16}
|
||||
title={t('Jump to end of video')}
|
||||
role="button"
|
||||
onClick={() => seekAbs(duration)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<div style={{ flexBasis: leftRightWidth }} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TimelineControls;
|
@ -1,22 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getSegColors } from '../util/colors';
|
||||
import { getSegColor } from '../util/colors';
|
||||
|
||||
const SegmentCutpointButton = ({ currentCutSeg, side, Icon, onClick, title, style }) => {
|
||||
const {
|
||||
segActiveBgColor: currentSegActiveBgColor,
|
||||
segBorderColor: currentSegBorderColor,
|
||||
} = getSegColors(currentCutSeg);
|
||||
const segColor = getSegColor(currentCutSeg);
|
||||
|
||||
const start = side === 'start';
|
||||
const border = `4px solid ${currentSegBorderColor}`;
|
||||
const border = `4px solid ${segColor.lighten(0.5).string()}`;
|
||||
const backgroundColor = segColor.lighten(0.5).alpha(0.5).string();
|
||||
|
||||
return (
|
||||
<Icon
|
||||
size={13}
|
||||
title={title}
|
||||
role="button"
|
||||
style={{ color: 'white', padding: start ? '4px 4px 4px 2px' : '4px 2px 4px 4px', borderLeft: start && border, borderRight: !start && border, background: currentSegActiveBgColor, borderRadius: 6, ...style }}
|
||||
style={{ color: 'white', padding: start ? '4px 4px 4px 2px' : '4px 2px 4px 4px', borderLeft: start && border, borderRight: !start && border, background: backgroundColor, borderRadius: 6, ...style }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
|
@ -17,15 +17,15 @@ function getColor(saturation, value, n) {
|
||||
/* eslint-enable */
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function getSegColors(seg) {
|
||||
if (!seg) return {};
|
||||
export function getSegColor(seg) {
|
||||
if (!seg) {
|
||||
return color({
|
||||
h: 0,
|
||||
s: 0,
|
||||
v: 100,
|
||||
});
|
||||
}
|
||||
const { segIndex } = seg;
|
||||
|
||||
const segColor = getColor(1, 0.95, segIndex);
|
||||
|
||||
return {
|
||||
segBgColor: segColor.alpha(0.5).string(),
|
||||
segActiveBgColor: segColor.lighten(0.5).alpha(0.5).string(),
|
||||
segBorderColor: segColor.lighten(0.5).string(),
|
||||
};
|
||||
return getColor(1, 0.95, segIndex);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user