import React, { memo, Fragment, useEffect, useMemo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SearchInput, PlusIcon, InlineAlert, UndoIcon, Paragraph, TakeActionIcon, IconButton, Button, DeleteIcon, AddIcon, Heading, Text, Dialog } from 'evergreen-ui'; import { FaMouse, FaPlus, FaStepForward, FaStepBackward } from 'react-icons/fa'; import Mousetrap from 'mousetrap'; import groupBy from 'lodash/groupBy'; import orderBy from 'lodash/orderBy'; import uniq from 'lodash/uniq'; import SetCutpointButton from './SetCutpointButton'; import SegmentCutpointButton from './SegmentCutpointButton'; const renderKeys = (keys) => keys.map((key, i) => ( {i > 0 && } {key.toUpperCase()} )); // From https://craig.is/killing/mice // For modifier keys you can use shift, ctrl, alt, or meta. // You can substitute option for alt and command for meta. const allModifiers = ['shift', 'ctrl', 'alt', 'meta']; function fixKeys(keys) { const replaced = keys.map((key) => { if (key === 'option') return 'alt'; if (key === 'command') return 'meta'; return key; }); const uniqed = uniq(replaced); const nonModifierKeys = keys.filter((key) => !allModifiers.includes(key)); if (nonModifierKeys.length === 0) return []; // only modifiers is invalid if (nonModifierKeys.length > 1) return []; // can only have one non-modifier return orderBy(uniqed, [key => key !== 'shift', key => key !== 'ctrl', key => key !== 'alt', key => key !== 'meta', key => key]); } const CreateBinding = memo(({ actionsMap, action, setCreatingBinding, onNewKeyBindingConfirmed, }) => { const { t } = useTranslation(); const [keysDown, setKeysDown] = useState([]); const validKeysDown = useMemo(() => fixKeys(keysDown), [keysDown]); const isShown = action != null; useEffect(() => { if (isShown) { setKeysDown([]); } }, [isShown]); const addKeyDown = useCallback((character) => setKeysDown((old) => [...new Set([...old, character])]), []); useEffect(() => { if (!isShown) return undefined; const mousetrap = new Mousetrap(); function handleKey(character, modifiers, e) { if (['keydown', 'keypress'].includes(e.type)) { addKeyDown(character); } e.preventDefault(); } const handleKeyOrig = mousetrap.handleKey; mousetrap.handleKey = handleKey; return () => { mousetrap.handleKey = handleKeyOrig; }; }, [addKeyDown, isShown]); const isComboInvalid = validKeysDown.length === 0 && keysDown.length > 0; return ( setCreatingBinding()} onConfirm={() => onNewKeyBindingConfirmed(action, keysDown)} onCancel={() => setCreatingBinding()} > {isShown ? (
{actionsMap[action].name} ({action}) {t('Please press your desired key combination. Make sure it doesn\'t conflict with any other binding or system hotkeys.')}
{renderKeys(validKeysDown.length > 0 ? validKeysDown : keysDown)}
{isComboInvalid && {t('Combination is invalid')}}
{!keysDown.includes('esc') && } {keysDown.length > 0 && }
) :
}
); }); const rowStyle = { display: 'flex', alignItems: 'center', margin: '6px 0' }; const KeyboardShortcuts = memo(({ keyBindings, setKeyBindings, resetKeyBindings, currentCutSeg, }) => { const { t } = useTranslation(); const { actionsMap, extraLinesPerCategory } = useMemo(() => { const playbackCategory = t('Playback'); const selectivePlaybackCategory = t('Playback/preview segments only'); const seekingCategory = t('Seeking'); const segmentsAndCutpointsCategory = t('Segments and cut points'); const zoomOperationsCategory = t('Timeline/zoom operations'); const outputCategory = t('Output actions'); const batchFilesCategory = t('Batch file list'); const otherCategory = t('Other operations'); const streamsCategory = t('Tracks'); return { extraLinesPerCategory: { [zoomOperationsCategory]: [
{t('Zoom in/out timeline')}
{t('Mouse scroll/wheel up/down')}
,
{t('Pan timeline')}
{t('Mouse scroll/wheel left/right')}
, ], }, actionsMap: { toggleLastCommands: { name: t('Last ffmpeg commands'), }, toggleKeyboardShortcuts: { name: t('Keyboard & mouse shortcuts'), }, // playbackCategory togglePlayResetSpeed: { name: t('Play/pause'), category: playbackCategory, }, togglePlayNoResetSpeed: { name: t('Play/pause (no reset speed)'), category: playbackCategory, }, play: { name: t('Play'), category: playbackCategory, }, pause: { name: t('Pause'), category: playbackCategory, }, increasePlaybackRate: { name: t('Speed up playback'), category: playbackCategory, }, reducePlaybackRate: { name: t('Slow down playback'), category: playbackCategory, }, increasePlaybackRateMore: { name: t('Speed up playback more'), category: playbackCategory, }, reducePlaybackRateMore: { name: t('Slow down playback more'), category: playbackCategory, }, increaseVolume: { name: t('Increase audio volume'), category: playbackCategory, }, decreaseVolume: { name: t('Decrease audio volume'), category: playbackCategory, }, reloadFile: { name: t('Reload current media'), category: playbackCategory, }, // selectivePlaybackCategory togglePlayOnlyCurrentSegment: { name: t('Play current segment once'), category: selectivePlaybackCategory, }, toggleLoopOnlyCurrentSegment: { name: t('Loop current segment'), category: selectivePlaybackCategory, }, toggleLoopStartEndOnlyCurrentSegment: { name: t('Loop beginning and end of current segment'), category: selectivePlaybackCategory, }, toggleLoopSelectedSegments: { name: t('Play selected segments in order'), category: selectivePlaybackCategory, }, // seekingCategory seekPreviousFrame: { name: t('Step backward 1 frame'), category: seekingCategory, }, seekNextFrame: { name: t('Step forward 1 frame'), category: seekingCategory, }, seekBackwards: { name: t('Seek backward 1 sec'), category: seekingCategory, }, seekForwards: { name: t('Seek forward 1 sec'), category: seekingCategory, }, seekBackwardsKeyframe: { name: t('Seek previous keyframe'), category: seekingCategory, }, seekForwardsKeyframe: { name: t('Seek next keyframe'), category: seekingCategory, }, seekBackwardsPercent: { name: t('Seek backward 1% of timeline at current zoom'), category: seekingCategory, }, seekForwardsPercent: { name: t('Seek forward 1% of timeline at current zoom'), category: seekingCategory, }, jumpCutStart: { name: t('Jump to current segment\'s start time'), category: seekingCategory, before: , }, jumpCutEnd: { name: t('Jump to current segment\'s end time'), category: seekingCategory, before: , }, jumpTimelineStart: { name: t('Jump to start of video'), category: seekingCategory, }, jumpTimelineEnd: { name: t('Jump to end of video'), category: seekingCategory, }, goToTimecode: { name: t('Seek to timecode'), category: seekingCategory, }, // segmentsAndCutpointsCategory addSegment: { name: t('Add cut segment'), category: segmentsAndCutpointsCategory, }, removeCurrentSegment: { name: t('Remove current segment'), category: segmentsAndCutpointsCategory, }, setCutStart: { name: t('Start current segment at current time'), category: segmentsAndCutpointsCategory, before: , }, setCutEnd: { name: t('End current segment at current time'), category: segmentsAndCutpointsCategory, before: , }, labelCurrentSegment: { name: t('Label current segment'), category: segmentsAndCutpointsCategory, }, splitCurrentSegment: { name: t('Split segment at cursor'), category: segmentsAndCutpointsCategory, }, duplicateCurrentSegment: { name: t('Duplicate current segment'), category: segmentsAndCutpointsCategory, }, jumpPrevSegment: { name: t('Jump to previous segment'), category: segmentsAndCutpointsCategory, }, jumpNextSegment: { name: t('Jump to next segment'), category: segmentsAndCutpointsCategory, }, reorderSegsByStartTime: { name: t('Reorder segments by start time'), category: segmentsAndCutpointsCategory, }, invertAllSegments: { name: t('Invert all segments on timeline'), category: segmentsAndCutpointsCategory, }, fillSegmentsGaps: { name: t('Fill gaps between segments'), category: segmentsAndCutpointsCategory, }, shiftAllSegmentTimes: { name: t('Shift all segments on timeline'), category: segmentsAndCutpointsCategory, }, alignSegmentTimesToKeyframes: { name: t('Align segment times to keyframes'), category: segmentsAndCutpointsCategory, }, createFixedDurationSegments: { name: t('Create fixed duration segments'), category: segmentsAndCutpointsCategory, }, createNumSegments: { name: t('Create num segments'), category: segmentsAndCutpointsCategory, }, createRandomSegments: { name: t('Create random segments'), category: segmentsAndCutpointsCategory, }, shuffleSegments: { name: t('Shuffle segments order'), category: segmentsAndCutpointsCategory, }, combineOverlappingSegments: { name: t('Combine overlapping segments'), category: segmentsAndCutpointsCategory, }, combineSelectedSegments: { name: t('Combine selected segments'), }, clearSegments: { name: t('Clear all segments'), category: segmentsAndCutpointsCategory, }, toggleSegmentsList: { name: t('Show sidebar'), category: segmentsAndCutpointsCategory, }, selectOnlyCurrentSegment: { name: t('Select only this segment'), category: segmentsAndCutpointsCategory, }, deselectAllSegments: { name: t('Deselect all segments'), category: segmentsAndCutpointsCategory, }, selectAllSegments: { name: t('Select all segments'), category: segmentsAndCutpointsCategory, }, toggleCurrentSegmentSelected: { name: t('Toggle current segment selected'), category: segmentsAndCutpointsCategory, }, invertSelectedSegments: { name: t('Invert selected segments'), category: segmentsAndCutpointsCategory, }, removeSelectedSegments: { name: t('Remove selected segments'), category: segmentsAndCutpointsCategory, }, // streamsCategory toggleStreamsSelector: { name: t('Edit tracks / metadata tags'), category: streamsCategory, }, extractAllStreams: { name: t('Extract all tracks'), category: streamsCategory, }, // zoomOperationsCategory timelineZoomIn: { name: t('Zoom in timeline'), category: zoomOperationsCategory, }, timelineZoomOut: { name: t('Zoom out timeline'), category: zoomOperationsCategory, }, timelineToggleComfortZoom: { name: t('Toggle zoom between 1x and a calculated comfortable zoom level'), category: zoomOperationsCategory, }, // outputCategory export: { name: t('Export segment(s)'), category: outputCategory, }, captureSnapshot: { name: t('Capture snapshot'), category: outputCategory, }, captureSnapshotAsCoverArt: { name: t('Set current frame as cover art'), category: outputCategory, }, extractCurrentSegmentFramesAsImages: { name: t('Extract frames from current segment as image files'), category: outputCategory, }, extractSelectedSegmentsFramesAsImages: { name: t('Extract frames from selected segments as image files'), category: outputCategory, }, cleanupFilesDialog: { name: t('Delete source file'), category: outputCategory, }, convertFormatBatch: { name: t('Batch convert files to supported format'), category: outputCategory, }, convertFormatCurrentFile: { name: t('Convert current file to supported format'), category: outputCategory, }, fixInvalidDuration: { name: t('Fix incorrect duration'), category: outputCategory, }, // batchFilesCategory batchPreviousFile: { name: t('Previous file'), category: batchFilesCategory, }, batchNextFile: { name: t('Next file'), category: batchFilesCategory, }, batchOpenSelectedFile: { name: t('Open selected file'), category: batchFilesCategory, }, closeBatch: { name: t('Close batch'), category: batchFilesCategory, }, concatBatch: { name: t('Merge/concatenate files'), category: batchFilesCategory, }, // otherCategory toggleKeyframeCutMode: { name: t('Cut mode'), category: otherCategory, }, toggleCaptureFormat: { name: t('Capture frame format'), category: otherCategory, }, toggleStripAudio: { name: t('Keep or discard audio tracks'), category: otherCategory, }, increaseRotation: { name: t('Change rotation'), category: otherCategory, }, setStartTimeOffset: { name: t('Set custom start offset/timecode'), category: otherCategory, }, undo: { name: t('Undo'), category: otherCategory, }, redo: { name: t('Redo'), category: otherCategory, }, copySegmentsToClipboard: { name: t('Copy selected segments times to clipboard'), category: otherCategory, }, closeActiveScreen: { name: t('Close current screen'), category: otherCategory, }, quit: { name: t('Quit LosslessCut'), category: otherCategory, }, }, }; }, [currentCutSeg, t]); useEffect(() => { // cleanup invalid bindings, to prevent renamed actions from blocking user to rebind const validBindings = keyBindings.filter(({ action }) => actionsMap[action]); if (validBindings.length !== keyBindings.length) { console.log(`Auto deleting ${keyBindings.length - validBindings.length} invalid key binding(s)`); setKeyBindings(validBindings); } }, [actionsMap, keyBindings, setKeyBindings]); const [creatingBinding, setCreatingBinding] = useState(); const [searchQuery, setSearchQuery] = useState(''); const actionEntries = useMemo(() => Object.entries(actionsMap).filter(([, { name }]) => !searchQuery || name.toLowerCase().includes(searchQuery.toLowerCase())), [actionsMap, searchQuery]); const categoriesWithActions = useMemo(() => Object.entries(groupBy(actionEntries, ([, { category }]) => category)), [actionEntries]); const onDeleteBindingClick = useCallback(({ action, keys }) => { // eslint-disable-next-line no-alert if (!window.confirm(t('Are you sure?'))) return; console.log('delete key binding', action, keys); setKeyBindings((existingBindings) => existingBindings.filter((existingBinding) => !(existingBinding.keys === keys && existingBinding.action === action))); }, [setKeyBindings, t]); const onResetClick = useCallback(() => { // eslint-disable-next-line no-alert if (!window.confirm(t('Are you sure?'))) return; resetKeyBindings(); }, [resetKeyBindings, t]); const onAddBindingClick = useCallback((action) => { setCreatingBinding(action); }, []); const stringifyKeys = (keys) => keys.join('+'); const onNewKeyBindingConfirmed = useCallback((action, keys) => { const fixedKeys = fixKeys(keys); if (fixedKeys.length === 0) return; const keysStr = stringifyKeys(fixedKeys); console.log('new key binding', action, keysStr); setKeyBindings((existingBindings) => { const haveDuplicate = existingBindings.some((existingBinding) => existingBinding.keys === keysStr); if (haveDuplicate) { console.log('trying to add duplicate'); return existingBindings; } console.log('saving key binding'); setCreatingBinding(); return [...existingBindings, { action, keys: keysStr }]; }); }, [setKeyBindings]); return ( <>
setSearchQuery(e.target.value)} placeholder="Search" width="100%" />
{categoriesWithActions.map(([category, actionsInCategory]) => (
{category !== 'undefined' && {category}} {actionsInCategory.map(([action, actionObj]) => { const actionName = (actionObj && actionObj.name) || action; const beforeContent = actionObj && actionObj.before; const bindingsForThisAction = keyBindings.filter((keyBinding) => keyBinding.action === action); return (
{beforeContent} {actionName}
{action}
{bindingsForThisAction.map(({ keys }) => (
{renderKeys(keys.split('+'))} onDeleteBindingClick({ action, keys })} />
))} {bindingsForThisAction.length === 0 && {t('No binding')}}
onAddBindingClick(action)} />
); })} {extraLinesPerCategory[category]}
))}
); }); const KeyboardShortcutsDialog = memo(({ isShown, onHide, keyBindings, setKeyBindings, resetKeyBindings, currentCutSeg, }) => { const { t } = useTranslation(); return ( {isShown ? :
}
); }); export default KeyboardShortcutsDialog;