import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; 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, FaSun } from 'react-icons/fa'; import { GiSoundWaves } from 'react-icons/gi'; // import useTraceUpdate from 'use-trace-update'; import { primaryTextColor, primaryColor, darkModeTransition } from './colors'; import SegmentCutpointButton from './components/SegmentCutpointButton'; import SetCutpointButton from './components/SetCutpointButton'; import ExportButton from './components/ExportButton'; import ToggleExportConfirm from './components/ToggleExportConfirm'; import CaptureFormatButton from './components/CaptureFormatButton'; import Select from './components/Select'; import SimpleModeButton from './components/SimpleModeButton'; import { withBlur, mirrorTransform, checkAppPath } from './util'; import { toast } from './swal'; import { getSegColor as getSegColorRaw } from './util/colors'; import { useSegColors } from './contexts'; import { formatDuration, parseDuration, isExactDurationMatch } from './util/duration'; import useUserSettings from './hooks/useUserSettings'; const { clipboard } = window.require('electron'); const zoomOptions = Array(13).fill().map((unused, z) => 2 ** z); const leftRightWidth = 100; const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, seekAbs, currentCutSeg, currentApparentCutSeg, isStart }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); const [cutTimeManual, setCutTimeManual] = useState(); // Clear manual overrides if upstream cut time has changed useEffect(() => { setCutTimeManual(); }, [setCutTimeManual, currentApparentCutSeg.start, currentApparentCutSeg.end]); const isCutTimeManualSet = () => cutTimeManual !== undefined; const border = useMemo(() => { const segColor = getSegColor(currentCutSeg); return `.1em solid ${darkMode ? segColor.desaturate(0.4).lightness(50).string() : segColor.desaturate(0.2).lightness(60).string()}`; }, [currentCutSeg, darkMode, getSegColor]); const cutTimeInputStyle = { border, borderRadius: 5, backgroundColor: 'var(--gray5)', transition: darkModeTransition, 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', }; const trySetTime = useCallback((timeWithOffset) => { const timeWithoutOffset = Math.max(timeWithOffset - startTimeOffset, 0); try { setCutTime(isStart ? 'start' : 'end', timeWithoutOffset); seekAbs(timeWithoutOffset); setCutTimeManual(); } 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 } }, [isStart, seekAbs, setCutTime, startTimeOffset]); const handleSubmit = useCallback((e) => { e.preventDefault(); // Don't proceed if not a valid time value const timeWithOffset = parseDuration(cutTimeManual); if (timeWithOffset === undefined) return; trySetTime(timeWithOffset); }, [cutTimeManual, trySetTime]); const parseAndSetCutTime = useCallback((text) => { // Don't proceed if not a valid time value const timeWithOffset = parseDuration(text); if (timeWithOffset === undefined) return; trySetTime(timeWithOffset); }, [trySetTime]); function handleCutTimeInput(text) { setCutTimeManual(text); if (isExactDurationMatch(text)) parseAndSetCutTime(text); } const tryPaste = useCallback((clipboardText) => { try { setCutTimeManual(clipboardText); parseAndSetCutTime(clipboardText); } catch (err) { console.error(err); } }, [parseAndSetCutTime]); const handleCutTimePaste = useCallback((e) => { e.preventDefault(); try { const clipboardData = e.clipboardData.getData('Text'); setCutTimeManual(clipboardData); parseAndSetCutTime(clipboardData); } catch (err) { console.error(err); } }, [parseAndSetCutTime]); const handleContextMenu = useCallback(() => { const text = clipboard.readText(); if (text) tryPaste(text); }, [tryPaste]); return (
handleCutTimeInput(e.target.value)} onPaste={handleCutTimePaste} onBlur={() => setCutTimeManual()} onContextMenu={handleContextMenu} value={isCutTimeManualSet() ? cutTimeManual : formatDuration({ seconds: cutTime + startTimeOffset })} />
); }); const BottomBar = memo(({ zoom, setZoom, timelineToggleComfortZoom, isRotationSet, rotation, areWeCutting, increaseRotation, cleanupFilesDialog, captureSnapshot, onExportPress, segmentsToExport, hasVideo, seekAbs, currentSegIndexSafe, cutSegments, currentCutSeg, setCutStart, setCutEnd, setCurrentSegIndex, jumpTimelineStart, jumpTimelineEnd, jumpCutEnd, jumpCutStart, startTimeOffset, setCutTime, currentApparentCutSeg, playing, shortStep, togglePlay, toggleLoopSelectedSegments, hasAudio, keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe, detectedFps, isFileOpened, selectedSegments, darkMode, setDarkMode, toggleEnableThumbnails, toggleWaveformMode, waveformMode, showThumbnails, }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); // ok this is a bit over-engineered but what the hell! const loopSelectedSegmentsButtonStyle = useMemo(() => { // cannot have less than 1 gradient element: const selectedSegmentsSafe = (selectedSegments.length > 1 ? selectedSegments : [selectedSegments[0], selectedSegments[0]]).slice(0, 10); const gradientColors = selectedSegmentsSafe.map((seg, i) => { const segColor = getSegColorRaw(seg); // make colors stronger, the more segments return `${segColor.alpha(Math.max(0.4, Math.min(0.8, selectedSegmentsSafe.length / 3))).string()} ${((i / (selectedSegmentsSafe.length - 1)) * 100).toFixed(1)}%`; }).join(', '); return { paddingLeft: 2, backgroundOffset: 30, background: `linear-gradient(90deg, ${gradientColors})`, border: '1px solid var(--gray8)', color: 'white', margin: '0px 5px 0 0px', display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 24, borderRadius: 4, }; }, [selectedSegments]); const { invertCutSegments, setInvertCutSegments, simpleMode, toggleSimpleMode, exportConfirmEnabled } = useUserSettings(); 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}°`; useEffect(() => { checkAppPath(); }, []); function renderJumpCutpointButton(direction) { const newIndex = currentSegIndexSafe + direction; const seg = cutSegments[newIndex]; const backgroundColor = seg && getSegColor(seg).desaturate(0.6).lightness(darkMode ? 35 : 55).string(); const opacity = seg ? undefined : 0.5; const text = seg ? `${newIndex + 1}` : '-'; const wide = text.length > 1; const segButtonStyle = { backgroundColor, opacity, padding: `6px ${wide ? 4 : 6}px`, borderRadius: 10, color: seg ? 'white' : undefined, fontSize: wide ? 12 : 14, width: 20, boxSizing: 'border-box', letterSpacing: -1, lineHeight: '10px', fontWeight: 'bold', margin: '0 6px', }; return (
0 ? t('Select next segment') : t('Select previous segment')} (${newIndex + 1})`} onClick={() => seg && setCurrentSegIndex(newIndex)} > {text}
); } const PlayPause = playing ? FaPause : FaPlay; return ( <>
{!simpleMode && ( <> setDarkMode((v) => !v)} style={{ padding: '0 .2em 0 .3em' }} /> {hasAudio && ( toggleWaveformMode()} /> )} {hasVideo && ( <> )} )}
{!simpleMode && ( <> {renderJumpCutpointButton(-1)} )} {!simpleMode && } seekClosestKeyframe(-1)} /> {!simpleMode && ( shortStep(-1)} /> )}
togglePlay()} style={{ background: primaryColor, margin: '2px 5px 0 5px', display: 'flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 17, color: 'white' }}>
{!simpleMode && ( shortStep(1)} /> )} seekClosestKeyframe(1)} /> {!simpleMode && } {!simpleMode && ( <> {renderJumpCutpointButton(1)} )}
{simpleMode &&
{t('Toggle advanced view')}
} {!simpleMode && ( <>
{Math.floor(zoom)}x
{detectedFps != null &&
{detectedFps.toFixed(3)}
} )}
{hasVideo && ( <> {isRotationSet && rotationStr} )} {!simpleMode && isFileOpened && ( )} {hasVideo && ( <> {!simpleMode && } )}
{(!simpleMode || !exportConfirmEnabled) && }
); }); export default BottomBar;