import type { CSSProperties, ReactEventHandler, FocusEventHandler, DragEventHandler } from 'react';
import { memo, useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { FaRegTimesCircle } from 'react-icons/fa';
import { MdRotate90DegreesCcw } from 'react-icons/md';
import { AnimatePresence, MotionConfig } from 'motion/react';
import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import { produce } from 'immer';
import screenfull from 'screenfull';
import type { IpcRendererEvent } from 'electron';
import { IoMdMenu } from 'react-icons/io';
import fromPairs from 'lodash/fromPairs';
import sum from 'lodash/sum';
import invariant from 'tiny-invariant';
import type { SweetAlertOptions } from 'sweetalert2';
import { Theme } from '@radix-ui/themes';

import useTimelineScroll from './hooks/useTimelineScroll';
import useUserSettingsRoot from './hooks/useUserSettingsRoot';
import useFfmpegOperations, { maybeMkDeepOutDir, OutputNotWritableError } from './hooks/useFfmpegOperations';
import useKeyframes from './hooks/useKeyframes';
import useWaveform from './hooks/useWaveform';
import useKeyboard from './hooks/useKeyboard';
import useFileFormatState from './hooks/useFileFormatState';
import useFrameCapture from './hooks/useFrameCapture';
import useSegments from './hooks/useSegments';
import useDirectoryAccess from './hooks/useDirectoryAccess';

import type { UserSettingsContextType, AppContextType, SegColorsContextType } from './contexts';
import { UserSettingsContext, SegColorsContext, AppContext } from './contexts';

import NoFileLoaded from './NoFileLoaded';
import MediaSourcePlayer from './MediaSourcePlayer';
import TopMenu from './TopMenu';
import LastCommands from './LastCommands';
import StreamsSelector from './StreamsSelector';
import SegmentList from './SegmentList';
import Settings from './components/Settings';
import Timeline from './Timeline';
import BottomBar from './BottomBar';
import ExportConfirm from './components/ExportConfirm';
import ValueTuners from './components/ValueTuners';
import VolumeControl from './components/VolumeControl';
import PlaybackStreamSelector from './components/PlaybackStreamSelector';
import BatchFilesList from './components/BatchFilesList';
import ConcatDialog from './components/ConcatDialog';
import KeyboardShortcuts from './components/KeyboardShortcuts';
import Working from './components/Working';
import OutputFormatSelect from './components/OutputFormatSelect';
import * as Dialog from './components/Dialog';

import { loadMifiLink, runStartupCheck } from './mifi';
import { darkModeTransition } from './colors';
import { getSegColor } from './util/colors';
import type {
  FileFfprobeMeta } from './ffmpeg';
import {
  getStreamFps, isCuttingStart, isCuttingEnd,
  readFileFfprobeMeta, getDefaultOutFormat,
  setCustomFfPath as ffmpegSetCustomFfPath,
  isIphoneHevc, isProblematicAvc1, tryMapChaptersToEdl,
  getTimecodeFromStreams, createChaptersFromSegments,
  RefuseOverwriteError, extractSubtitleTrackToSegments,
  mapRecommendedDefaultFormat,
  getFfCommandLine,
} from './ffmpeg';
import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, enableVideoTrack, enableAudioTrack, canHtml5PlayerPlayStreams, isMatroska } from './util/streams';
import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore';
import { formatYouTube, getFrameCountRaw, formatTsvHuman } from './edlFormats';
import {
  getOutPath, getOutDir,
  isStoreBuild, dragPreventer,
  havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, findExistingHtml5FriendlyFile,
  isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, readVideoTs, readDirRecursively, getImportProjectType,
  calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString,
  isMuxNotSupported,
  getDownloadMediaOutPath,
  isAbortedError,
  shootConfetti,
  isMasBuild,
  readFileStats,
  makeSourceFileAccessError,
  transferTimestamps,
  getOutFileExtension,
  getSuffixedOutPath,
} from './util';
import getSwal, { errorToast, showPlaybackFailedMessage } from './swal';
import { adjustRate } from './util/rate-calculator';
import { askExtractFramesAsImages } from './dialogs/extractFrames';
import type { CleanupChoicesType, OpenFileResponse } from './dialogs';
import { askForOutDir, askForImportChapters, askForFileOpenAction, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, showOutputNotWritable, deleteFiles, mustDisallowVob, toastError } from './dialogs';
import { openSendReportDialog } from './reporting';
import { sortSegments, convertSegmentsToChaptersWithGaps, hasAnySegmentOverlap, isDurationValid, getPlaybackAction, getSegmentTags, filterNonMarkers, isInitialSegment } from './segments';
import type { GenerateMergedOutFileNamesParams, GeneratedOutFileNames } from './util/outputNameTemplate';
import { generateCutFileNames as generateCutFileNamesRaw, generateCutMergedFileNames as generateCutMergedFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultCutFileTemplate, defaultCutMergedFileTemplate, defaultMergedFileTemplate } from './util/outputNameTemplate';
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
import BigWaveform from './components/BigWaveform';

import type { BatchFile, Chapter, EdlExportType, EdlFileType, EdlImportType, FfmpegCommandLog, FilesMeta, FileStats, ParamsByFile, PlaybackMode, SegmentBase, SegmentColorIndex, SegmentTags, StateSegment, TunerType } from './types';
import { goToTimecodeDirectArgsSchema, openFilesActionArgsSchema } from './types';
import type { CaptureFormat, KeyboardAction, ApiActionRequest } from '../../common/types.js';
import type { FFprobeChapter, FFprobeStream } from '../../common/ffprobe.js';
import { parseFfprobeDuration } from '../../common/util.js';

import useLoading from './hooks/useLoading';
import useVideo from './hooks/useVideo';
import useTimecode from './hooks/useTimecode';
import useSegmentsAutoSave from './hooks/useSegmentsAutoSave';
import useThumbnails from './hooks/useThumbnails';
import useSubtitles from './hooks/useSubtitles';
import useStreamsMeta from './hooks/useStreamsMeta';
import { bottomStyle, videoStyle } from './styles';
import styles from './App.module.css';
import { DirectoryAccessDeclinedError, UserFacingError } from '../errors';
import SwalContainer from './components/SwalContainer';
import ErrorDialog from './components/ErrorDialog';
import useErrorHandling from './hooks/useErrorHandling';
import GenericDialog, { useDialog } from './components/GenericDialog';
import useHtml5ify from './hooks/useHtml5ify';
import WhatsNew from './components/WhatsNew';
import mainApi from './mainApi.js';
import type { AppEvent } from '../../main/index.js';
import { appName } from '../../main/common.js';

const { ipcRenderer, webUtils } = window.require('electron');
const { lstat } = window.require('node:fs/promises');
const { parse: parsePath, join: pathJoin, basename, dirname } = window.require('node:path');
const { hasDisabledNetworking, pathToFileURL, lossyMode, isLinux } = window.require('@electron/remote').require('./index.js');


const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback();
// eslint-disable-next-line unicorn/prefer-top-level-await
hevcPlaybackSupportedPromise.catch((err) => console.error(err));

function emitEvent(appEvent: AppEvent) {
  ipcRenderer.send('appEvent', appEvent satisfies AppEvent);
}

function App() {
  const { t } = useTranslation();

  // Per project state
  const [ffmpegCommandLog, setFfmpegCommandLog] = useState<FfmpegCommandLog>([]);
  const [rotation, setRotation] = useState(360);
  const [progress, setProgress] = useState<number>();
  const [startTimeOffset, setStartTimeOffset] = useState(0);
  const [filePath, setFilePath] = useState<string>();
  const [fileDuration, setFileDuration] = useState<number>();
  const [externalFilesMeta, setExternalFilesMeta] = useState<FilesMeta>({});
  const [paramsByFile, setParamsByFile] = useState<ParamsByFile>(new Map());
  const [detectedFps, setDetectedFps] = useState<number>();
  const [mainFileMeta, setMainFileMeta] = useState<{ ffprobeMeta: FileFfprobeMeta, stats: FileStats }>();
  const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
  const [concatDialogOpen, setConcatDialogOpen] = useState(false);
  const [zoomUnrounded, setZoom] = useState(1);
  const [shortestFlag, setShortestFlag] = useState(false);
  const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0);
  const [activeVideoStreamIndex, setActiveVideoStreamIndex] = useState<number>();
  const [activeAudioStreamIndexes, setActiveAudioStreamIndexes] = useState<Set<number>>(new Set());
  const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState<number>();
  const [hideCompatPlayer, setHideCompatPlayer] = useState(false);
  const [exportConfirmOpen, setExportConfirmOpen] = useState(false);
  const [cacheBuster, setCacheBuster] = useState(0);
  const [currentFileExportCount, setCurrentFileExportCount] = useState(0);

  const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();

  // State per application launch
  const [ffmpegInfo, setFfmpegInfo] = useState<Awaited<ReturnType<typeof runStartupCheck>>>();
  const lastOpenedPathRef = useRef<string>(undefined);
  const [showRightBar, setShowRightBar] = useState(true);
  const [lastCommandsVisible, setLastCommandsVisible] = useState(false);
  const [settingsVisible, setSettingsVisible] = useState(false);
  const [tunerVisible, setTunerVisible] = useState<TunerType>();
  const [keyboardShortcutsVisible, setKeyboardShortcutsVisible] = useState(false);
  const [mifiLink, setMifiLink] = useState<unknown>();
  const [alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles] = useState(false);
  const [editingSegmentTagsSegmentIndex, setEditingSegmentTagsSegmentIndex] = useState<number>();
  const [editingSegmentTags, setEditingSegmentTags] = useState<SegmentTags>();
  const [mediaSourceQuality, setMediaSourceQuality] = useState(0);
  const [encBitrate, setEncBitrate] = useState<number | undefined>();
  const [exportCount, setExportCount] = useState(0);
  const [maxKeyframes, setMaxKeyframes] = useState(10000);

  const incrementMediaSourceQuality = useCallback(() => setMediaSourceQuality((v) => (v + 1) % mediaSourceQualities.length), []);

  // Batch state / concat files
  const [batchFiles, setBatchFiles] = useState<BatchFile[]>([]);
  const [selectedBatchFiles, setSelectedBatchFiles] = useState<string[]>([]);

  const allUserSettings = useUserSettingsRoot();
  const { captureFormat, keyframeCut, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableImportChapters, enableAskForFileOpenAction, playbackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, segmentsToChapters, simpleMode, cutFileTemplate, cutMergedFileTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, safeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, darkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, thumbnailsEnabled, keyframesEnabled, reducedMotion, ffmpegHwaccel } = allUserSettings.settings;
  const { setCaptureFormat, setCustomOutDir, setKeyframeCut, setPlaybackVolume, setExportConfirmEnabled, setSimpleMode, setOutFormatLocked, setSafeOutputFileName, setKeyBindings, resetKeyBindings, setStoreProjectInWorkingDir, setCleanupChoices, toggleDarkMode, setWaveformMode, setThumbnailsEnabled, setKeyframesEnabled, prefersReducedMotion, customOutDir } = allUserSettings;

  const [showAdvancedSettings, setShowAdvancedSettings] = useState(!simpleMode);

  const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling();

  const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, openShiftSegmentsDialog, openDecimateDialog } = useDialog();

  // Note that each action may be multiple key bindings and this will only be the first binding for each action
  const keyBindingByAction = useMemo(() => Object.fromEntries(keyBindings.map((binding) => [binding.action, binding])), [keyBindings]);

  const { working, setWorking, workingRef, abortWorking } = useLoading();
  const { videoRef, videoContainerRef, playbackRate, setPlaybackRate, outputPlaybackRate, setOutputPlaybackRate, commandedTime, seekAbs, playingRef, getRelevantTime, setPlaying, onSeeked, relevantTime, onStartPlaying, setCommandedTime, setOutputPlaybackRateState, commandedTimeRef, onStopPlaying, onVideoAbort, playerTime, setPlayerTime, playbackMode, setPlaybackMode, playbackModeRef, playing, play, pause, seekRel } = useVideo({ filePath });
  const { timecodePlaceholder, formatTimecode, parseTimecode, getFrameCount, promptTimecode } = useTimecode({ detectedFps, timecodeFormat, showGenericDialog });
  const { loadSubtitle, subtitlesByStreamId, setSubtitlesByStreamId } = useSubtitles();

  const fileDurationNonZero = isDurationValid(fileDuration) ? fileDuration : 1;
  const zoom = Math.floor(zoomUnrounded);
  const zoomedDuration = isDurationValid(fileDuration) ? fileDuration / zoom : undefined;
  const zoomWindowEndTime = useMemo(() => (zoomedDuration != null ? zoomWindowStartTime + zoomedDuration : undefined), [zoomedDuration, zoomWindowStartTime]);

  useEffect(() => setDocumentTitle({ filePath, working: working?.text, progress }), [progress, filePath, working?.text]);

  useEffect(() => {
    mainApi.setProgressBar(progress ?? -1);
  }, [progress]);

  useEffect(() => {
    ffmpegSetCustomFfPath(customFfPath);
  }, [customFfPath]);

  const cutFileTemplateOrDefault = cutFileTemplate ?? defaultCutFileTemplate;
  const cutMergedFileTemplateOrDefault = cutMergedFileTemplate ?? defaultCutMergedFileTemplate;
  const mergedFileTemplateOrDefault = mergedFileTemplate ?? defaultMergedFileTemplate;

  useEffect(() => {
    i18n.changeLanguage(language ?? undefined).catch(console.error);
    ipcRenderer.send('setLanguage', language);
  }, [language]);

  const isFileOpened = !!filePath;

  const [hoveringTime, setHoveringTime] = useState<number>();

  const displayTime = (hoveringTime != null && isFileOpened && !playing ? hoveringTime : relevantTime) + startTimeOffset;

  const onOutputFormatUserChange = useCallback((newFormat: string) => {
    setFileFormat(newFormat);
    if (outFormatLocked) {
      setOutFormatLocked(newFormat === detectedFileFormat ? undefined : newFormat);
    }
  }, [detectedFileFormat, outFormatLocked, setFileFormat, setOutFormatLocked]);

  const previousPlaybackVolume = useRef(playbackVolume);
  const toggleMuted = useCallback(() => {
    setPlaybackVolume((volume) => {
      if (volume === 0) {
        return previousPlaybackVolume.current || 1;
      }
      previousPlaybackVolume.current = volume;
      return 0;
    });
  }, [setPlaybackVolume]);

  const toggleShowThumbnails = useCallback(() => setThumbnailsEnabled((v) => !v), [setThumbnailsEnabled]);

  const hideAllNotifications = hideNotifications === 'all';

  const showNotification = useCallback((opts: SweetAlertOptions) => {
    if (!hideAllNotifications) {
      getSwal().toast.fire(opts);
    }
  }, [hideAllNotifications]);

  const showOsNotification = useCallback((text: string) => {
    if (hideOsNotifications == null) {
      // on Linux app name is not shown in notification, see https://github.com/mifi/lossless-cut/issues/2794
      if (isLinux) mainApi.sendOsNotification({ title: appName, body: text });
      else mainApi.sendOsNotification({ title: text });
    }
  }, [hideOsNotifications]);

  const toggleExportConfirmEnabled = useCallback(() => setExportConfirmEnabled((v) => {
    const newVal = !v;
    showNotification({ text: newVal ? i18n.t('Export options will be shown before exporting.') : i18n.t('Export options will not be shown before exporting.') });
    return newVal;
  }), [setExportConfirmEnabled, showNotification]);

  const toggleShowKeyframes = useCallback(() => {
    setKeyframesEnabled((old) => {
      const enabled = !old;
      if (enabled && !calcShouldShowKeyframes(zoomedDuration)) {
        showNotification({ text: i18n.t('Key frames will show on the timeline. You need to zoom in to view them') });
      }
      return enabled;
    });
  }, [setKeyframesEnabled, showNotification, zoomedDuration]);

  const appendLastCommandsLog = useCallback((command: string) => {
    setFfmpegCommandLog((old) => [...old, { command, time: new Date() }]);
  }, []);
  const appendFfmpegCommandLog = useCallback((args: string[]) => appendLastCommandsLog(getFfCommandLine('ffmpeg', args)), [appendLastCommandsLog]);

  const toggleSegmentsList = useCallback(() => setShowRightBar((v) => !v), []);

  const toggleSafeOutputFileName = useCallback(() => setSafeOutputFileName((v) => {
    if (v) showNotification({ icon: 'info', text: i18n.t('Output file name will not be sanitized, and any special characters will be preserved. This may cause the export to fail and can cause other funny issues. Use at your own risk!') });
    return !v;
  }), [setSafeOutputFileName, showNotification]);

  useEffect(() => {
    if (videoRef.current) videoRef.current.volume = playbackVolume;
  }, [playbackVolume, videoRef]);

  const mainStreams = useMemo(() => mainFileMeta?.ffprobeMeta.streams ?? [], [mainFileMeta?.ffprobeMeta.streams]);
  const mainFileFormat = useMemo(() => mainFileMeta?.ffprobeMeta.format, [mainFileMeta?.ffprobeMeta.format]);
  const mainFileChapters = useMemo(() => mainFileMeta?.ffprobeMeta.chapters, [mainFileMeta?.ffprobeMeta.chapters]);

  const subtitleStreams = useMemo(() => getSubtitleStreams(mainStreams), [mainStreams]);
  const videoStreams = useMemo(() => getRealVideoStreams(mainStreams), [mainStreams]);
  const audioStreams = useMemo(() => getAudioStreams(mainStreams), [mainStreams]);

  const mainVideoStream = useMemo(() => videoStreams[0], [videoStreams]);
  const mainAudioStream = useMemo(() => audioStreams[0], [audioStreams]);

  const activeSubtitle = useMemo(() => (activeSubtitleStreamIndex != null ? subtitlesByStreamId[activeSubtitleStreamIndex] : undefined), [activeSubtitleStreamIndex, subtitlesByStreamId]);
  const activeVideoStream = useMemo(() => (activeVideoStreamIndex != null ? videoStreams.find((stream) => stream.index === activeVideoStreamIndex) : undefined) ?? mainVideoStream, [activeVideoStreamIndex, mainVideoStream, videoStreams]);
  const activeAudioStreams = useMemo(() => {
    let ret: FFprobeStream[] = [];
    if (activeAudioStreamIndexes.size > 0) ret = audioStreams.filter((stream) => activeAudioStreamIndexes.has(stream.index));
    if (ret.length === 0 && mainAudioStream != null) ret = [mainAudioStream];
    return ret;
  }, [activeAudioStreamIndexes, audioStreams, mainAudioStream]);

  // 360 means we don't modify rotation gtrgt
  const isRotationSet = rotation !== 360;
  const effectiveRotation = useMemo(() => (isRotationSet ? rotation : (activeVideoStream?.tags?.rotate ? parseInt(activeVideoStream.tags.rotate, 10) : undefined)), [isRotationSet, activeVideoStream, rotation]);

  const zoomAbs = useCallback((fn: (v: number) => number) => setZoom((z) => Math.min(Math.max(fn(z), 1), zoomMax)), []);
  const zoomRel = useCallback((rel: number) => zoomAbs((z) => z + (rel * (1 + (z / 10)))), [zoomAbs]);

  const comfortZoom = isDurationValid(fileDuration) ? Math.max(fileDuration / 100, 1) : undefined;
  const timelineToggleComfortZoom = useCallback(() => {
    if (!comfortZoom) return;

    zoomAbs((prevZoom) => {
      if (prevZoom === 1) return comfortZoom;
      return 1;
    });
  }, [comfortZoom, zoomAbs]);


  const maxLabelLength = safeOutputFileName ? 100 : 500;

  const checkFileOpened = useCallback(() => {
    if (isFileOpened) return true;
    getSwal().toast.fire({ icon: 'info', title: i18n.t('You need to open a media file first') });
    return false;
  }, [isFileOpened]);

  const {
    cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, modifySelectedSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter,
  } = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta: mainFileMeta?.ffprobeMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode, ffmpegHwaccel });

  const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments });

  const { nonCopiedExtraStreams, exportExtraStreams, mainCopiedThumbnailStreams, numStreamsToCopy, toggleStripVideo, toggleStripAudio, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, copyStreamIdsByFile, setCopyStreamIdsByFile, copyFileStreams, mainCopiedStreams, setCopyStreamIdsForPath, toggleCopyStreamId, isCopyingStreamId, toggleCopyStreamIds, changeEnabledStreamsFilter, applyEnabledStreamsFilter, enabledStreamsFilter, toggleCopyAllStreamsForPath } = useStreamsMeta({ mainStreams, externalFilesMeta, filePath, autoExportExtraStreams, showGenericDialog });

  const onDurationChange = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
    // Some files report duration infinity first, then proper duration later
    // Sometimes after seeking to end of file, duration might change
    const { duration: durationNew } = e.currentTarget;
    console.log('onDurationChange', durationNew);
    if (isDurationValid(durationNew)) {
      setFileDuration(durationNew);
      maybeCreateFullLengthSegment(durationNew);
    }
  }, [maybeCreateFullLengthSegment]);

  const segmentsAtCursor = useMemo(() => findSegmentsAtCursor(commandedTime).map((index) => cutSegments[index]), [commandedTime, cutSegments, findSegmentsAtCursor]);
  const firstSegmentAtCursor = useMemo(() => segmentsAtCursor[0], [segmentsAtCursor]);

  const segmentAtCursorRef = useRef<StateSegment>(undefined);
  useEffect(() => {
    segmentAtCursorRef.current = firstSegmentAtCursor;
  }, [firstSegmentAtCursor]);

  const seekRelPercent = useCallback((val: number) => {
    if (!isDurationValid(zoomedDuration)) return;
    seekRel(val * zoomedDuration);
  }, [seekRel, zoomedDuration]);

  const shortStep = useCallback((direction: number) => {
    if (!videoRef.current) return;

    // If we don't know fps, just assume 30 (for example if unknown audio file)
    const fps = detectedFps || 30;

    // try to align with frame
    const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current.currentTime);
    invariant(currentTimeNearestFrameNumber != null);
    const nextFrame = currentTimeNearestFrameNumber + direction;
    seekAbs(nextFrame / fps);
  }, [detectedFps, seekAbs, videoRef]);

  const jumpSegStart = useCallback((index: number) => {
    const seg = cutSegments[index];
    if (seg != null) seekAbs(seg.start);
  }, [cutSegments, seekAbs]);
  const jumpSegEnd = useCallback((index: number) => {
    const seg = cutSegments[index];
    if (seg?.end != null) seekAbs(seg.end);
  }, [cutSegments, seekAbs]);
  const jumpCutStart = useCallback(() => jumpSegStart(currentSegIndexSafe), [currentSegIndexSafe, jumpSegStart]);
  const jumpCutEnd = useCallback(() => jumpSegEnd(currentSegIndexSafe), [currentSegIndexSafe, jumpSegEnd]);
  const jumpTimelineStart = useCallback(() => seekAbs(0), [seekAbs]);
  const jumpTimelineEnd = useCallback(() => seekAbs(fileDuration), [fileDuration, seekAbs]);

  // const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]);

  const outputDir = getOutDir(customOutDir, filePath);

  const increaseRotation = useCallback(() => {
    setRotation((r) => (r + 90) % 450);
    setHideCompatPlayer(false);
    // Matroska is known not to work, so we warn user. See https://github.com/mifi/lossless-cut/discussions/661
    const supportsRotation = !isMatroska(fileFormat);
    if (!supportsRotation) showNotification({ text: i18n.t('Lossless rotation might not work with this file format. You may try changing to MP4') });
  }, [fileFormat, showNotification]);

  const { ensureWritableOutDir, ensureAccessToSourceDir } = useDirectoryAccess({ setCustomOutDir });

  const toggleCaptureFormat = useCallback(() => setCaptureFormat((f) => {
    const captureFormats: CaptureFormat[] = ['jpeg', 'png', 'webp'];
    let index = captureFormats.indexOf(f);
    if (index === -1) index = 0;
    index += 1;
    if (index >= captureFormats.length) index = 0;
    const newCaptureFormat = captureFormats[index];
    invariant(newCaptureFormat != null);
    return newCaptureFormat;
  }), [setCaptureFormat]);

  const toggleKeyframeCut = useCallback((showMessage?: boolean) => setKeyframeCut((val) => {
    const newVal = !val;
    if (showMessage) {
      if (newVal) showNotification({ title: i18n.t('Keyframe cut enabled'), text: i18n.t('Will now cut at the nearest keyframe before the desired start cutpoint. This is recommended for most files.') });
      else showNotification({ title: i18n.t('Keyframe cut disabled'), text: i18n.t('Will now cut at the exact position, but may leave an empty portion at the beginning of the file. You may have to set the cutpoint a few frames before the next keyframe to achieve a precise cut'), timer: 7000 });
    }
    return newVal;
  }), [showNotification, setKeyframeCut]);

  const toggleSimpleMode = useCallback(() => setSimpleMode((v) => {
    showNotification({ text: v ? i18n.t('Advanced view has been enabled. You will now also see non-essential buttons and functions') : i18n.t('Advanced view disabled. You will now see only the most essential buttons and functions') });
    return !v;
  }), [setSimpleMode, showNotification]);

  const effectiveExportMode = useMemo(() => {
    if (segmentsToChaptersOnly) return 'segments_to_chapters';
    if (autoMerge && autoDeleteMergedSegments) return 'merge';
    if (autoMerge) return 'merge+separate';
    return 'separate';
  }, [autoDeleteMergedSegments, autoMerge, segmentsToChaptersOnly]);

  const changeOutDir = useCallback(async () => {
    const newOutDir = await askForOutDir(outputDir);
    if (newOutDir) setCustomOutDir(newOutDir);
  }, [outputDir, setCustomOutDir]);

  const ensureWritableOutDirWithFilePath = useCallback(async ({ outDir }: { outDir: string | undefined }) => ensureWritableOutDir({ inputPath: filePath, outDir }), [ensureWritableOutDir, filePath]);

  const toggleStoreProjectInWorkingDir = useCallback(async () => {
    const newValue = !storeProjectInWorkingDir;
    const path = getProjectFileSavePath(newValue);
    if (path) { // path will be falsy if no file loaded
      try {
        await ensureAccessToSourceDir(path);
      } catch (err) {
        if (err instanceof DirectoryAccessDeclinedError) return;
        console.error(err);
      }
    }
    setStoreProjectInWorkingDir(newValue);
  }, [ensureAccessToSourceDir, getProjectFileSavePath, setStoreProjectInWorkingDir, storeProjectInWorkingDir]);

  const userSettingsContext = useMemo<UserSettingsContextType>(() => {
    const { settings, ...rest } = allUserSettings;

    return {
      ...settings, ...rest, toggleCaptureFormat, changeOutDir, toggleKeyframeCut, toggleExportConfirmEnabled, toggleSimpleMode, toggleSafeOutputFileName, effectiveExportMode, ensureWritableOutDirWithFilePath,
    };
  }, [allUserSettings, changeOutDir, effectiveExportMode, ensureWritableOutDirWithFilePath, toggleCaptureFormat, toggleExportConfirmEnabled, toggleKeyframeCut, toggleSafeOutputFileName, toggleSimpleMode]);

  const segColorsContext = useMemo<SegColorsContextType>(() => ({
    getSegColor: (seg: SegmentColorIndex | undefined) => {
      const color = getSegColor(seg);
      return preferStrongColors ? color.desaturate(0.2) : color.desaturate(0.6);
    },
    nextSegColorIndex: isInitialSegment(cutSegments) ? segColorCounter : segColorCounter + 1,
  }), [cutSegments, preferStrongColors, segColorCounter]);

  const onActiveSubtitleChange = useCallback(async (index?: number) => {
    if (index == null) {
      setActiveSubtitleStreamIndex(undefined);
      return;
    }
    if (subtitlesByStreamId[index]) { // Already loaded
      setActiveSubtitleStreamIndex(index);
      return;
    }
    const subtitleStream = index != null && subtitleStreams.find((s) => s.index === index);
    if (!subtitleStream || workingRef.current) return;

    setWorking({ text: i18n.t('Loading subtitle') });
    try {
      await withErrorHandling(async () => {
        invariant(filePath != null);
        await loadSubtitle({ filePath, index, subtitleStream });
        setActiveSubtitleStreamIndex(index);
      }, i18n.t('Failed to load subtitles from track {{index}}', { index }));
    } finally {
      setWorking(undefined);
    }
  }, [subtitlesByStreamId, subtitleStreams, workingRef, setWorking, withErrorHandling, filePath, loadSubtitle]);

  const onActiveVideoStreamChange = useCallback((videoStreamIndex?: number) => {
    invariant(videoRef.current);
    setHideCompatPlayer(false);
    enableVideoTrack(videoRef.current, videoStreamIndex);
    setActiveVideoStreamIndex(videoStreamIndex);
  }, [videoRef]);

  const onActiveAudioStreamsChange = useCallback((audioStreamIndexes: Set<number>) => {
    invariant(videoRef.current);
    setHideCompatPlayer(false);
    enableAudioTrack(videoRef.current, [...audioStreamIndexes][0]);
    setActiveAudioStreamIndexes(audioStreamIndexes);
  }, [videoRef]);

  const allFilesMeta = useMemo(() => ({
    ...externalFilesMeta,
    ...(filePath && mainFileMeta != null ? { [filePath]: mainFileMeta.ffprobeMeta } : {}),
  }), [externalFilesMeta, filePath, mainFileMeta]);

  // total number of streams for ALL files
  const numStreamsTotal = Object.values(allFilesMeta).flatMap(({ streams }) => streams).length;

  const hasAudio = !!mainAudioStream;
  const hasVideo = !!mainVideoStream;

  const forceBigWaveform = !hasVideo && hasAudio;

  const toggleWaveformMode = useCallback(() => {
    if (forceBigWaveform) return;

    // eslint-disable-next-line unicorn/prefer-switch
    if (waveformModePreference === 'waveform') {
      setWaveformMode('big-waveform');
    } else if (waveformModePreference === 'big-waveform') {
      setWaveformMode(undefined);
    } else {
      showNotification({ text: i18n.t('Mini-waveform has been enabled. Click again to enable full-screen waveform') });
      setWaveformMode('waveform');
    }
  }, [forceBigWaveform, setWaveformMode, showNotification, waveformModePreference]);

  const waveformMode = forceBigWaveform ? 'big-waveform' : waveformModePreference;

  const waveformEnabled = hasAudio && waveformMode != null;
  const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform';
  const showThumbnails = thumbnailsEnabled && hasVideo;

  const { thumbnailsSorted, setThumbnails } = useThumbnails({ filePath, zoomedDuration, zoomWindowStartTime, showThumbnails });

  const { neighbouringKeyFrames, findNearestKeyFrameTime, keyframeByNumber, readAllKeyframes } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow, maxKeyframes, currentCutSegOrWholeTimeline, setWorking, setMaxKeyframes, handleError });
  const { waveforms, overviewWaveform, renderOverviewWaveform } = useWaveform({ filePath, relevantTime, waveformEnabled, audioStream: activeAudioStreams[0], ffmpegExtractWindow, fileDuration });

  const currentFrame = useMemo(() => {
    const frameNum = getFrameCount(commandedTime);
    if (frameNum == null) return undefined;
    return keyframeByNumber[frameNum];
  }, [commandedTime, getFrameCount, keyframeByNumber]);

  const generateOverviewWaveform = useCallback(async () => {
    if (working) return;
    try {
      setWorking({ text: t('Generating full overview waveform, this may take a few minutes.') });
      await renderOverviewWaveform();
    } finally {
      setWorking();
    }
  }, [renderOverviewWaveform, setWorking, t, working]);

  const shouldShowKeyframes = keyframesEnabled && hasVideo && calcShouldShowKeyframes(zoomedDuration);
  const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration) || overviewWaveform != null;

  const areWeCutting = useMemo(() => segmentsToExport.some(({ start, end }) => isCuttingStart(start) || isCuttingEnd(end, fileDuration)), [fileDuration, segmentsToExport]);
  const needSmartCut = areWeCutting && enableSmartCut;
  const isEncoding = needSmartCut || lossyMode != null;

  const {
    concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, decimate, extractStreams, tryDeleteFiles,
  } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog, ffmpegHwaccel });

  const { previewFilePath, setPreviewFilePath, usingDummyVideo, setUsingDummyVideo, userHtml5ifyCurrentFile, convertFormatBatch, html5ifyAndLoadWithPreferences } = useHtml5ify({
    filePath, hasVideo, hasAudio, workingRef, setWorking, ensureWritableOutDir, customOutDir, batchFiles, enableAutoHtml5ify, setProgress, html5ify, html5ifyDummy, withErrorHandling, showGenericDialog,
  });

  const compatPlayerRequired = (
    // if user selected an explicit video or audio stream, and the html5 player does not have any track index corresponding to the selected stream index
    (
      (activeVideoStreamIndex != null || activeAudioStreamIndexes.size === 1)
      && videoRef.current != null
      && !canHtml5PlayerPlayStreams(videoRef.current, activeVideoStreamIndex, [...activeAudioStreamIndexes][0])
    )
    // or if selected multiple audio streams (html5 video element doesn't support that)
    || activeAudioStreamIndexes.size > 1
  );
  // if user selected a rotation, but they might want to turn off the rotation preview
  // but allow the user to disable
  const compatPlayerWanted = (isRotationSet && !hideCompatPlayer)
    || usingDummyVideo;

  const compatPlayerEnabled = (compatPlayerRequired || compatPlayerWanted) && (activeVideoStream != null || activeAudioStreams.length > 0);

  useEffect(() => {
    // Reset the user preference when we go from not having compat player to having it
    // eslint-disable-next-line react-hooks/set-state-in-effect
    if (compatPlayerEnabled) setHideCompatPlayer(false);
  }, [compatPlayerEnabled]);

  const shouldShowPlaybackStreamSelector = videoStreams.length > 0 || audioStreams.length > 0 || (subtitleStreams.length > 0 && !compatPlayerEnabled);

  const usingPreviewFile = !!previewFilePath;
  const effectiveFilePath = previewFilePath || filePath;
  useEffect(() => {
    if (!effectiveFilePath) return undefined;
    const url = (() => {
      const baseUrl = pathToFileURL(effectiveFilePath).href;
      // https://github.com/mifi/lossless-cut/issues/1674
      if (cacheBuster !== 0) {
        const qs = new URLSearchParams();
        qs.set('t', String(cacheBuster));
        return `${baseUrl}?${qs.toString()}`;
      }
      return baseUrl;
    })();

    const video = videoRef.current;
    if (video) {
      video.src = url;
    }

    return () => {
      // Apparently this is the correct way to unload a video
      // See commit 260603f613632850c301f72f0fca267b06b688a0
      // and https://github.com/mifi/lossless-cut/issues/2907
      if (video) {
        video.pause();
        video.removeAttribute('src'); // empty source
        video.load();
      }
    };
  }, [cacheBuster, effectiveFilePath, videoRef]);

  const resetState = useCallback(() => {
    console.log('State reset');
    const video = videoRef.current;
    setCommandedTime(0);
    video!.currentTime = 0;
    setPlaybackRate(1);

    // setWorking();
    setPreviewFilePath(undefined);
    setUsingDummyVideo(false);
    setPlaying(false);
    playingRef.current = false;
    setPlaybackMode(undefined);
    setFileDuration(undefined);
    cutSegmentsHistory.history[0] = []; // in case we have exceeded capacity
    cutSegmentsHistory.go(0);
    setDetectedFileFormat(undefined);
    setRotation(360);
    setProgress(undefined);
    setStartTimeOffset(0);
    setFilePath(undefined);
    setExternalFilesMeta({});
    setParamsByFile(new Map());
    setDetectedFps(undefined);
    setMainFileMeta(undefined);
    setCopyStreamIdsByFile({});
    setStreamsSelectorShown(false);
    setZoom(1);
    setThumbnails([]);
    setShortestFlag(false);
    setZoomWindowStartTime(0);
    setSubtitlesByStreamId({});
    setActiveAudioStreamIndexes(new Set());
    setActiveVideoStreamIndex(undefined);
    setActiveSubtitleStreamIndex(undefined);
    setHideCompatPlayer(false);
    setExportConfirmOpen(false);
    setOutputPlaybackRateState(1);
    setCurrentFileExportCount(0);
  }, [videoRef, setCommandedTime, setPlaybackRate, setPreviewFilePath, setUsingDummyVideo, setPlaying, playingRef, setPlaybackMode, cutSegmentsHistory, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setOutputPlaybackRateState]);


  const showNotNativelySupportedMessage = useCallback(() => {
    showNotification({ timer: 13000, text: i18n.t('File is not natively supported. Preview playback may be slow and of low quality, but the final export will be lossless. You may convert the file from the menu for a better preview.') });
  }, [showNotification]);

  const showPreviewFileLoadedMessage = useCallback((fileName: string) => {
    showNotification({ icon: 'info', text: i18n.t('Loaded existing preview file: {{ fileName }}', { fileName }) });
  }, [showNotification]);

  const handleHideCompatPlayerClick = useCallback(() => {
    setHideCompatPlayer(true);
    setPreviewFilePath(undefined);
    setUsingDummyVideo(false);
  }, [setHideCompatPlayer, setPreviewFilePath, setUsingDummyVideo]);

  const { captureFrameFromTag, captureFrameFromFfmpeg, captureFrameToClipboard, captureFramesRange } = useFrameCapture({ appendFfmpegCommandLog, formatTimecode, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, fileDuration });

  const getNewJumpIndex = (oldIndex: number, direction: -1 | 1) => Math.max(oldIndex + direction, 0);

  const jumpSeg = useCallback((params: ({ abs: number } | { rel: -1 | 1 }) & { seek?: true }) => {
    const clamp = (v: number) => Math.max(0, Math.min(v, cutSegments.length - 1));

    const seek = (index: number) => {
      if (params.seek && cutSegments[index]) seekAbs(cutSegments[index].start);
    };

    if ('abs' in params) {
      const index = clamp(params.abs);
      setCurrentSegIndex(index);
      seek(index);
    } else {
      setCurrentSegIndex((old) => {
        const index = clamp(getNewJumpIndex(old, params.rel));
        seek(index);
        return index;
      });
    }
  }, [cutSegments, seekAbs, setCurrentSegIndex]);

  const togglePlay = useCallback(({ resetPlaybackRate, requestPlaybackMode }: { resetPlaybackRate?: boolean, requestPlaybackMode?: PlaybackMode } | undefined = {}) => {
    setPlaybackMode(requestPlaybackMode);

    if (playingRef.current) {
      pause();
      return;
    }

    // If we are using a special playback mode, we might need to do more:
    if (playbackModeRef.current != null) {
      const selectedSegmentsWithoutMarkers = filterNonMarkers(selectedSegments);
      const selectedSegmentAtCursor = selectedSegmentsWithoutMarkers.find((selectedSegment) => selectedSegment.segId === segmentAtCursorRef.current?.segId);
      const isSomeSegmentAtCursor = selectedSegmentAtCursor != null && commandedTimeRef.current != null && selectedSegmentAtCursor.end != null && selectedSegmentAtCursor.end - commandedTimeRef.current > 0.1;
      if (!isSomeSegmentAtCursor) { // if a segment is already at cursor, don't do anything
        // if no segment at cursor, and looping playback mode, continue looping
        if (playbackModeRef.current === 'play-selected-segments' || playbackModeRef.current === 'loop-selected-segments') {
          const firstSelectedSegment = selectedSegmentsWithoutMarkers[0];
          if (firstSelectedSegment != null) {
            const index = cutSegments.findIndex((segment) => segment.segId === firstSelectedSegment.segId);
            if (index !== -1) setCurrentSegIndex(index);
            seekAbs(firstSelectedSegment.start);
          }
        } else if (currentCutSeg != null) {
          // for all other playback modes, seek to start of current segment
          seekAbs(currentCutSeg.start);
        }
      }
    }
    play(resetPlaybackRate);
  }, [setPlaybackMode, playingRef, playbackModeRef, play, pause, selectedSegments, commandedTimeRef, currentCutSeg, cutSegments, setCurrentSegIndex, seekAbs]);

  const onTimeUpdate = useCallback<ReactEventHandler<HTMLVideoElement>>((e) => {
    const { currentTime } = e.currentTarget;
    if (playerTime === currentTime) return;
    setPlayerTime(currentTime);

    const segmentsAtCursorIndexes = findSegmentsAtCursor(commandedTimeRef.current);
    const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0];
    const playingSegment = firstSegmentAtCursorIndex != null ? cutSegments[firstSegmentAtCursorIndex] : undefined;

    if (playbackModeRef.current != null && playingRef.current && playingSegment && playingSegment.end != null) {
      const nextAction = getPlaybackAction({ playbackMode: playbackModeRef.current, currentTime, playingSegment: { start: playingSegment.start, end: playingSegment.end } });

      const exit = () => {
        setPlaybackMode(undefined);
        pause();
      };

      if (nextAction != null) {
        console.log(nextAction);
        if (nextAction.nextSegment) {
          const selectedSegmentsWithoutMarkers = filterNonMarkers(selectedSegments);

          const index = selectedSegmentsWithoutMarkers.findIndex((selectedSegment) => selectedSegment.segId === playingSegment.segId);
          let newSelectedSegmentIndex = getNewJumpIndex(index !== -1 ? index : 0, 1);
          if (newSelectedSegmentIndex > selectedSegmentsWithoutMarkers.length - 1) {
            // have reached end of last segment
            if (playbackModeRef.current === 'loop-selected-segments') newSelectedSegmentIndex = 0; // start over
            else if (playbackModeRef.current === 'play-selected-segments') exit();
          }
          const nextSelectedSegment = selectedSegmentsWithoutMarkers[newSelectedSegmentIndex];
          if (nextSelectedSegment != null) {
            seekAbs(nextSelectedSegment.start);
            const newIndex = cutSegments.findIndex((segment) => segment.segId === nextSelectedSegment.segId);
            if (newIndex !== -1) setCurrentSegIndex(newIndex);
          }
        }
        if (nextAction.seekTo != null) {
          seekAbs(nextAction.seekTo);
        }
        if (nextAction.exit) {
          exit();
        }
      }
    }
  }, [commandedTimeRef, cutSegments, findSegmentsAtCursor, pause, playbackModeRef, playerTime, playingRef, seekAbs, selectedSegments, setCurrentSegIndex, setPlaybackMode, setPlayerTime]);

  const closeFileWithConfirm = useCallback(async () => {
    if (!isFileOpened || workingRef.current) return;

    if (askBeforeClose && !(await confirmDialog({ focusConfirm: true, description: i18n.t('Are you sure you want to close the current file?') }))) return;

    resetState();
    clearSegments();
  }, [isFileOpened, workingRef, askBeforeClose, confirmDialog, resetState, clearSegments]);

  const closeBatch = useCallback(async () => {
    if (askBeforeClose && !(await confirmDialog({ focusConfirm: true, description: i18n.t('Are you sure you want to close the loaded batch of files?') }))) return;
    setBatchFiles([]);
    setSelectedBatchFiles([]);
  }, [askBeforeClose, confirmDialog]);

  const batchListRemoveFile = useCallback((path: string | undefined) => {
    setBatchFiles((existingBatch) => {
      const index = existingBatch.findIndex((existingFile) => existingFile.path === path);
      if (index === -1) return existingBatch;
      const newBatch = [...existingBatch];
      newBatch.splice(index, 1);
      const newItemAtIndex = newBatch[index];
      if (newItemAtIndex != null) setSelectedBatchFiles([newItemAtIndex.path]);
      else if (newBatch.length > 0) setSelectedBatchFiles([newBatch[0]!.path]);
      else setSelectedBatchFiles([]);
      return newBatch;
    });
  }, []);

  const commonSettings = useMemo(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { keyBindings: _keyBindings, ...rest } = allUserSettings.settings;
    return { ...rest, ffmpegVersion: ffmpegInfo?.program_version.version };
  }, [allUserSettings.settings, ffmpegInfo?.program_version.version]);

  const openSendReportDialogWithState = useCallback(async (err?: unknown) => {
    const state = {
      ...commonSettings,

      filePath,
      fileFormat,
      externalFilesMeta,
      mainStreams,
      copyStreamIdsByFile,
      cutSegments: cutSegments.map((s) => ({ start: s.start, end: s.end })),
      mainFileFormat,
      rotation,
      shortestFlag,
      effectiveExportMode,
    };

    openSendReportDialog({ err, state });
  }, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormat, mainStreams, rotation, shortestFlag]);

  const openSendConcatReportDialogWithState = useCallback(async (err: unknown, reportState?: object) => {
    const state = { ...commonSettings, ...reportState };
    openSendReportDialog({ err, state });
  }, [commonSettings]);

  const handleExportFailed = useCallback(async (err: unknown) => {
    const sendErrorReport = await showExportFailedDialog({ fileFormat, safeOutputFileName });
    if (sendErrorReport) openSendReportDialogWithState(err);
  }, [fileFormat, safeOutputFileName, openSendReportDialogWithState]);

  const handleConcatFailed = useCallback(async (err: unknown, reportState: object) => {
    const sendErrorReport = await showConcatFailedDialog({ fileFormat });
    if (sendErrorReport) openSendConcatReportDialogWithState(err, reportState);
  }, [fileFormat, openSendConcatReportDialogWithState]);

  const generateCutFileNames = useCallback(async (template: string) => {
    invariant(fileFormat != null && outputDir != null && filePath != null);
    return generateCutFileNamesRaw({ fileDuration, exportCount, currentFileExportCount, segmentsToExport, template, formatTimecode, isCustomFormatSelected, fileFormat, sourceFile: { path: filePath, ...mainFileMeta }, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
  }, [currentFileExportCount, exportCount, fileDuration, fileFormat, filePath, formatTimecode, isCustomFormatSelected, mainFileMeta, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);

  const generateCutMergedFileNames = useCallback(async (template: string) => {
    invariant(fileFormat != null && outputDir != null && filePath != null);
    return generateCutMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, sourceFile: { path: filePath, ...mainFileMeta }, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segLabels: segmentsToExport.map((seg) => seg.name ?? '') });
  }, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, mainFileMeta, maxLabelLength, outputDir, safeOutputFileName, segmentsToExport]);

  const generateMergedFileNames = useCallback(async (params: GenerateMergedOutFileNamesParams) => (
    generateMergedFileNamesRaw({ ...params, isCustomFormatSelected, safeOutputFileName, maxLabelLength, exportCount })
  ), [exportCount, isCustomFormatSelected, maxLabelLength, safeOutputFileName]);

  const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, clearBatchFilesAfterConcat, generatedFileNames }: {
    paths: string[],
    includeAllStreams: boolean,
    streams: FFprobeStream[],
    fileFormat: string,
    clearBatchFilesAfterConcat: boolean,
    generatedFileNames: GeneratedOutFileNames,
  }) => {
    if (workingRef.current) return;

    const firstPath = paths[0];
    if (!firstPath) return;

    try {
      // need to ensure the output dir is writable, because the user might not yet have opened a file, and so MAS might not yet have access to write the dir
      const newCustomOutDir = await ensureWritableOutDir({ inputPath: firstPath, outDir: customOutDir });
      if (newCustomOutDir !== customOutDir) {
        // throw user back to the concat dialog because now things might have changed (which could affect overwriting files etc!)
        // also if the user cancels the dialog, `DirectoryAccessDeclinedError` will be thrown and we will return (see catch below)
        return;
      }

      // only after ensuring out dir access, we can close the concat dialog
      setConcatDialogOpen(false);
      setWorking({ text: i18n.t('Merging') });

      const warnings = new Set<string>();
      const notices = new Set<string>();

      const { fileNames, problems } = generatedFileNames;
      if (problems.error != null) {
        console.warn('Merged file name invalid, using default instead', fileNames[0]);
        warnings.add(problems.error);
        warnings.add(t('Fell back to default output file name'));
      }

      const outDir = getOutDir(customOutDir, firstPath);

      const [fileName] = fileNames;
      invariant(fileName != null);
      const outPath = getOutPath({ customOutDir, filePath: firstPath, fileName });

      const chaptersFromSegments = segmentsToChapters
        ? await createChaptersFromSegments({ paths, defaultChapterNames: paths.map((path) => parsePath(path).name), useFileChapters: true })
        : undefined;

      let inputSize: number;
      try {
        inputSize = sum(await readFileSizes(paths));
      } catch (err) {
        console.warn('Unable to read input file sizes', err);
        throw makeSourceFileAccessError();
      }

      // console.log('merge', paths);
      const metadataFromPath = paths[0];
      invariant(metadataFromPath != null);

      await maybeMkDeepOutDir({ outputDir: outDir, fileOutPath: outPath });

      const { haveExcludedStreams } = await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments });

      const outputSize = await readFileSize(outPath); // * 1.06; // testing:)
      const sizeCheckResult = checkFileSizes(inputSize, outputSize);
      if (sizeCheckResult != null) warnings.add(sizeCheckResult);

      if (clearBatchFilesAfterConcat) closeBatch();
      if (!includeAllStreams && haveExcludedStreams) notices.add(i18n.t('Some extra tracks have been discarded. You can change this option before merging.'));
      if (!enableOverwriteOutput) warnings.add(i18n.t('Overwrite output setting is disabled and some files might have been skipped.'));

      if (!hideAllNotifications) {
        showOsNotification(i18n.t('Merge finished'));
        openConcatFinishedDialog({ filePath: outPath, notices: [...notices], warnings: [...warnings] });
      }
    } catch (err) {
      if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return;

      showOsNotification(i18n.t('Failed to merge'));

      if (isExecaError(err)) {
        console.log('stdout:', getStdioString(err.stdout));
        console.error('stderr:', getStdioString(err.stderr));

        if (isOutOfSpaceError(err)) {
          showDiskFull();
          return;
        }
        if (isMuxNotSupported(err)) {
          showMuxNotSupported();
          return;
        }
      }

      if (err instanceof OutputNotWritableError) {
        showOutputNotWritable();
        return;
      }

      if (err instanceof UserFacingError) {
        errorToast(err.message);
        return;
      }

      const reportState = { includeAllStreams, streams, outFormat, clearBatchFilesAfterConcat };
      handleConcatFailed(err, reportState);
    } finally {
      setWorking(undefined);
      setProgress(undefined);
    }
  }, [workingRef, ensureWritableOutDir, customOutDir, setWorking, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, enableOverwriteOutput, hideAllNotifications, t, showOsNotification, openConcatFinishedDialog, handleConcatFailed]);

  const cleanupFiles = useCallback(async (cleanupChoices2: CleanupChoicesType) => {
    // Store paths before we reset state
    const savedPaths = { previewFilePath, sourceFilePath: filePath, projectFilePath: projectFileSavePath };

    if (cleanupChoices2.closeFile) {
      batchListRemoveFile(savedPaths.sourceFilePath);

      // close the file
      resetState();
      clearSegments();
    }

    await withErrorHandling(async () => {
      const abortController = new AbortController();
      setWorking({ text: i18n.t('Cleaning up'), abortController });
      console.log('Cleaning up files', cleanupChoices2);

      const pathsToDelete: string[] = [];
      if (cleanupChoices2.trashTmpFiles && savedPaths.previewFilePath) pathsToDelete.push(savedPaths.previewFilePath);
      if (cleanupChoices2.trashProjectFile && savedPaths.projectFilePath) pathsToDelete.push(savedPaths.projectFilePath);
      if (cleanupChoices2.trashSourceFile && savedPaths.sourceFilePath) pathsToDelete.push(savedPaths.sourceFilePath);

      await deleteFiles({ paths: pathsToDelete, deleteIfTrashFails: cleanupChoices2.deleteIfTrashFails, signal: abortController.signal });
    }, i18n.t('Unable to delete file'));
  }, [batchListRemoveFile, clearSegments, filePath, previewFilePath, projectFileSavePath, resetState, setWorking, withErrorHandling]);

  const askForCleanupChoices = useCallback(async () => {
    const trashResponse = await openCleanupFilesDialog(cleanupChoices);
    if (trashResponse != null) setCleanupChoices(trashResponse); // Store for next time, if not canceled
    return trashResponse;
  }, [cleanupChoices, openCleanupFilesDialog, setCleanupChoices]);

  const cleanupFilesDialog = useCallback(async () => {
    if (!isFileOpened || workingRef.current) return;

    try {
      const newCleanupChoices = cleanupChoices.askForCleanup ? await askForCleanupChoices() : cleanupChoices;
      // only if not canceled
      if (newCleanupChoices != null) await cleanupFiles(newCleanupChoices);
    } finally {
      setWorking(undefined);
    }
  }, [askForCleanupChoices, cleanupChoices, cleanupFiles, isFileOpened, setWorking, workingRef]);

  const closeExportConfirm = useCallback(() => setExportConfirmOpen(false), []);

  const willMerge = segmentsToExport.length > 1 && autoMerge;

  const onExportConfirm = useCallback(async () => {
    invariant(filePath != null && outputDir != null);
    emitEvent({ eventName: 'export-start', path: filePath });

    if (numStreamsToCopy === 0) {
      errorToast(i18n.t('No tracks selected for export'));
      return;
    }

    if (haveInvalidSegs) {
      errorToast(i18n.t('Start time must be before end time'));
      return;
    }

    setStreamsSelectorShown(false);
    setExportConfirmOpen(false);

    if (workingRef.current) return;
    try {
      setWorking({ text: i18n.t('Exporting') });

      // Special segments-to-chapters mode:
      let chaptersToAdd: Chapter[] | undefined;
      if (segmentsToChaptersOnly) {
        const sortedSegments = sortSegments(segmentsOrInverse.selected);
        if (hasAnySegmentOverlap(sortedSegments)) {
          errorToast(i18n.t('Make sure you have no overlapping segments.'));
          return;
        }
        // matroska supports gaps, so we can use segments directly
        chaptersToAdd = isMatroska(fileFormat) ? sortedSegments : convertSegmentsToChaptersWithGaps(sortedSegments);
      }

      console.log('cutFileTemplate', cutFileTemplateOrDefault);

      const notices = new Set<string>();
      const warnings = new Set<string>();

      let cutFileNames: string[];
      // When deleting merged segments, use the default template so that we e.g. don't risk creating a folder structure
      // https://github.com/mifi/lossless-cut/issues/2637
      if (willMerge && autoDeleteMergedSegments) {
        const generated = await generateCutFileNames(defaultCutFileTemplate);
        cutFileNames = generated.fileNames;
      } else {
        const generated = await generateCutFileNames(cutFileTemplateOrDefault);
        cutFileNames = generated.fileNames;
        if (generated.problems.error != null) {
          console.warn('Output segments file name invalid, using default instead', generated.fileNames);
          warnings.add(generated.problems.error);
          warnings.add(t('Fell back to default output file name'));
        }
      }

      // throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })();
      const outFiles = await cutMultiple({
        outputDir,
        customOutDir,
        outFormat: fileFormat,
        fileDuration,
        rotation: isRotationSet ? effectiveRotation : undefined,
        copyFileStreams,
        allFilesMeta,
        keyframeCut,
        segments: segmentsToExport,
        cutFileNames,
        onProgress: setProgress,
        shortestFlag,
        ffmpegExperimental,
        preserveMetadata,
        preserveMetadataOnMerge,
        preserveMovData,
        preserveChapters,
        movFastStart,
        avoidNegativeTs,
        paramsByFile,
        chapters: chaptersToAdd,
        detectedFps,
      });

      let mergedOutFilePath: string | undefined;

      if (willMerge) {
        console.log('cutMergedFileTemplateOrDefault', cutMergedFileTemplateOrDefault);

        setProgress(0);
        setWorking({ text: i18n.t('Merging') });

        const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined;

        const { fileNames, problems } = await generateCutMergedFileNames(cutMergedFileTemplateOrDefault);
        if (problems.error != null) {
          console.warn('Merged file name invalid, using default instead', fileNames[0]);
          warnings.add(problems.error);
          warnings.add(t('Fell back to default output file name'));
        }

        const [fileName] = fileNames;
        invariant(fileName != null);
        mergedOutFilePath = getOutPath({ customOutDir, filePath, fileName });

        await concatCutSegments({
          customOutDir,
          outFormat: fileFormat,
          segmentPaths: outFiles.map((f) => f.path),
          ffmpegExperimental,
          preserveMovData,
          movFastStart,
          onProgress: setProgress,
          chapterNames,
          preserveMetadataOnMerge,
          mergedOutFilePath,
        });

        // don't delete existing files that were not created by losslesscut now (due to overwrite disabled) https://github.com/mifi/lossless-cut/issues/2436
        const createdOutFiles = outFiles.flatMap((f) => (f.created ? [f.path] : []));
        if (autoDeleteMergedSegments) await tryDeleteFiles(createdOutFiles);
      }

      if (!enableOverwriteOutput) warnings.add(i18n.t('Overwrite output setting is disabled and some files might have been skipped.'));

      if (!exportConfirmEnabled) notices.add(i18n.t('Export options are not shown. You can enable export options by clicking the icon right next to the export button.'));

      invariant(mainFileFormat != null);
      // https://github.com/mifi/lossless-cut/issues/329
      if (isIphoneHevc(mainFileFormat, mainStreams)) warnings.add(i18n.t('There is a known issue with cutting iPhone HEVC videos. The output file may not work in all players.'));

      // https://github.com/mifi/lossless-cut/issues/280
      if (!ffmpegExperimental && isProblematicAvc1(fileFormat, mainStreams)) warnings.add(i18n.t('There is a known problem with this file type, and the output might not be playable. You can work around this problem by enabling the "Experimental flag" under Settings.'));

      if (exportExtraStreams) {
        try {
          setProgress(undefined); // If extracting extra streams takes a long time, prevent loader from being stuck at 100%
          setWorking({ text: i18n.t('Extracting {{count}} unprocessable tracks', { count: nonCopiedExtraStreams.length }) });
          await extractStreams({ customOutDir, streams: nonCopiedExtraStreams });
          notices.add(i18n.t('Unprocessable streams were exported as separate files.'));
        } catch (err) {
          console.error('Extra stream export failed', err);
          warnings.add(i18n.t('Unable to export unprocessable streams.'));
        }
      }

      if (areWeCutting) notices.add(i18n.t('Cutpoints may be inaccurate.'));

      if (simpleMode && !prefersReducedMotion) shootConfetti({ ticks: 50 });

      if (cleanupChoices.cleanupAfterExport) {
        const newCleanupChoices = cleanupChoices.askForCleanup ? await askForCleanupChoices() : cleanupChoices;
        // only if not canceled
        if (newCleanupChoices) await cleanupFiles(newCleanupChoices);
      }

      // Note: this should be after cleanup, so we don't accidentally open two dialogs at the same time, leading to error *and* success dialog simultaneously https://github.com/mifi/lossless-cut/issues/2609
      const exportedPaths = willMerge && mergedOutFilePath != null ? [mergedOutFilePath] : outFiles.map((f) => f.path);
      const [revealPath] = exportedPaths;
      invariant(revealPath != null);
      if (!hideAllNotifications) {
        showOsNotification(i18n.t('Export finished'));
        openCutFinishedDialog({ filePath: revealPath, warnings: [...warnings], notices: [...notices] });
      }

      setExportCount((c) => c + 1);
      setCurrentFileExportCount((c) => c + 1);

      emitEvent({ eventName: 'export-complete', paths: exportedPaths });
    } catch (err) {
      emitEvent({ eventName: 'export-complete' });

      if (isAbortedError(err)) return;

      showOsNotification(i18n.t('Failed to export'));

      if (isExecaError(err)) {
        console.log('stdout:', getStdioString(err.stdout));
        console.error('stderr:', getStdioString(err.stderr));

        if (isOutOfSpaceError(err)) {
          showDiskFull();
          return;
        }
        if (isMuxNotSupported(err)) {
          showMuxNotSupported();
          return;
        }
      }

      if (err instanceof OutputNotWritableError) {
        showOutputNotWritable();
        return;
      }

      if (err instanceof UserFacingError) {
        errorToast(err.message);
        return;
      }

      handleExportFailed(err);
    } finally {
      setWorking(undefined);
      setProgress(undefined);
    }
  }, [filePath, numStreamsToCopy, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, cutFileTemplateOrDefault, generateCutFileNames, cutMultiple, outputDir, customOutDir, fileFormat, fileDuration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, segmentsToExport, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, paramsByFile, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormat, mainStreams, exportExtraStreams, areWeCutting, simpleMode, prefersReducedMotion, cleanupChoices, hideAllNotifications, segmentsOrInverse.selected, t, cutMergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateCutMergedFileNames, concatCutSegments, autoDeleteMergedSegments, tryDeleteFiles, nonCopiedExtraStreams, extractStreams, askForCleanupChoices, cleanupFiles, showOsNotification, openCutFinishedDialog, handleExportFailed]);

  const onExportPress = useCallback(async () => {
    if (!filePath) return;

    if (!exportConfirmEnabled || exportConfirmOpen) {
      await onExportConfirm();
    } else {
      setExportConfirmOpen(true);
      setStreamsSelectorShown(false);
    }
  }, [filePath, exportConfirmEnabled, exportConfirmOpen, onExportConfirm]);

  const captureSnapshot = useCallback(async () => {
    if (!filePath || workingRef.current) return;
    try {
      setWorking({ text: i18n.t('Exporting') });

      await withErrorHandling(async () => {
        const currentTime = getRelevantTime();
        const video = videoRef.current;
        invariant(video != null);
        const usingFfmpeg = usingPreviewFile || captureFrameMethod === 'ffmpeg';
        const outPath = usingFfmpeg
          ? await captureFrameFromFfmpeg({ customOutDir, filePath, time: currentTime, captureFormat, quality: captureFrameQuality })
          : await captureFrameFromTag({ customOutDir, filePath, time: currentTime, captureFormat, quality: captureFrameQuality, video });

        if (simpleMode && !prefersReducedMotion) shootConfetti();
        if (!hideAllNotifications) openExportFinishedDialog({ filePath: outPath, children: <div style={{ wordWrap: 'break-word' }}>{i18n.t('Screenshot captured to:')} {outPath}</div> });
      }, i18n.t('Failed to capture frame'));
    } finally {
      setWorking(undefined);
    }
  }, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, simpleMode, prefersReducedMotion, hideAllNotifications, openExportFinishedDialog]);

  const shiftAllSegmentTimes = useCallback(async () => {
    const shift = await openShiftSegmentsDialog({ inputPlaceholder: timecodePlaceholder, parseTimecode });
    if (shift == null) return;

    const { startShift, endShift } = shift;
    await modifySelectedSegmentTimes((segment) => {
      const newSegment = { ...segment };
      if (startShift != null) {
        newSegment.start += startShift;
      }
      if (endShift != null && newSegment.end != null) {
        newSegment.end += endShift;
      }
      return newSegment;
    });
  }, [modifySelectedSegmentTimes, openShiftSegmentsDialog, parseTimecode, timecodePlaceholder]);

  const captureSnapshotToClipboard = useCallback(async () => {
    if (!filePath || workingRef.current) return;
    try {
      setWorking({ text: i18n.t('Exporting') });

      await withErrorHandling(async () => {
        const currentTime = getRelevantTime();
        await captureFrameToClipboard({ filePath, time: currentTime, quality: captureFrameQuality });

        if (simpleMode && !prefersReducedMotion) shootConfetti();
        if (!hideAllNotifications) getSwal().toast.fire({ icon: 'info', title: t('Screenshot captured clipboard') });
      }, i18n.t('Failed to capture frame'));
    } finally {
      setWorking(undefined);
    }
  }, [captureFrameQuality, captureFrameToClipboard, filePath, getRelevantTime, hideAllNotifications, prefersReducedMotion, setWorking, simpleMode, t, withErrorHandling, workingRef]);

  const extractSegmentsFramesAsImages = useCallback(async (segments: SegmentBase[]) => {
    if (!filePath || detectedFps == null || workingRef.current || segments.length === 0) return;
    const segmentsNumFrames = segments.reduce((acc, { start, end }) => acc + (end == null ? 1 : (getFrameCount(end - start) ?? 0)), 0);
    // If all segments are markers, we shall export every marker as a file and therefore we don't have to ask user
    const areAllSegmentsMarkers = segments.every((seg) => seg.end == null);
    const captureFramesResponse = areAllSegmentsMarkers
      ? { filter: undefined, estimatedMaxNumFiles: segmentsNumFrames }
      : await askExtractFramesAsImages({ segmentsNumFrames, plural: segments.length > 1, fps: detectedFps });

    if (captureFramesResponse == null) return;

    try {
      setWorking({ text: i18n.t('Extracting frames') });
      console.log('Extracting frames as images', { captureFramesResponse });

      setProgress(0);

      let lastOutPath: string | undefined;

      const segmentProgresses: Record<number, number> = {};
      const handleSegmentProgress = (segIndex: number, segmentProgress: number) => {
        segmentProgresses[segIndex] = segmentProgress;
        const totalProgress = segments.reduce((acc, _ignored, index) => acc + (segmentProgresses[index] ?? 0), 0);
        setProgress(totalProgress / segments.length);
      };

      // eslint-disable-next-line no-restricted-syntax
      for (const [index, segment] of segments.entries()) {
        const { start, end } = segment;
        invariant(filePath != null);
        // eslint-disable-next-line no-await-in-loop
        lastOutPath = await captureFramesRange({ customOutDir, filePath, fps: detectedFps, fromTime: start, toTime: end, estimatedMaxNumFiles: captureFramesResponse.estimatedMaxNumFiles, captureFormat, quality: captureFrameQuality, filter: captureFramesResponse.filter, outputTimestamps: captureFrameFileNameFormat === 'timestamp', onProgress: (segmentProgress) => handleSegmentProgress(index, segmentProgress) });
      }
      if (!hideAllNotifications && lastOutPath != null) {
        showOsNotification(i18n.t('Frames have been extracted'));
        openExportFinishedDialog({ filePath: lastOutPath, children: <div style={{ wordWrap: 'break-word' }}>{i18n.t('Frames extracted to: {{path}}', { path: outputDir })}</div> });
      }
    } catch (err) {
      showOsNotification(i18n.t('Failed to extract frames'));
      handleError({ err, title: i18n.t('Failed to extract frames') });
    } finally {
      setWorking(undefined);
      setProgress(undefined);
    }
  }, [filePath, detectedFps, workingRef, getFrameCount, setWorking, hideAllNotifications, captureFramesRange, customOutDir, captureFormat, captureFrameQuality, captureFrameFileNameFormat, showOsNotification, openExportFinishedDialog, outputDir, handleError]);

  const extractCurrentSegmentFramesAsImages = useCallback(() => {
    if (currentCutSeg != null) extractSegmentsFramesAsImages([currentCutSeg]);
  }, [currentCutSeg, extractSegmentsFramesAsImages]);

  const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentsFramesAsImages(selectedSegments), [extractSegmentsFramesAsImages, selectedSegments]);

  const userChangePlaybackRate = useCallback((dir: number, rateMultiplier?: number) => {
    const video = videoRef.current;
    if (!playingRef.current) {
      video!.play();
    } else {
      const newRate = adjustRate(video!.playbackRate, dir, rateMultiplier);
      setPlaybackRate(newRate);
    }
  }, [playingRef, setPlaybackRate, videoRef]);

  const loadEdlFile = useCallback(async ({ path, type, append = false }: { path: string, type: EdlFileType, append?: boolean }) => {
    console.log('Loading EDL file', type, path, append);
    // cannot clamDuration because the duration is null (if no file loaded) or duration of a different file (if switching files)
    // because of how react state works
    loadCutSegments({ segments: await readEdlFile({ type, path, fps: detectedFps }), append });
  }, [detectedFps, loadCutSegments]);

  const loadSubtitleTrackToSegments = useCallback(async (streamId: number) => {
    invariant(filePath != null);
    setWorking(true);
    try {
      setStreamsSelectorShown(false);
      loadCutSegments({ segments: await extractSubtitleTrackToSegments(filePath, streamId), append: true, clampDuration: fileDuration });
    } finally {
      setWorking(undefined);
    }
  }, [fileDuration, filePath, loadCutSegments, setWorking]);

  const loadMedia = useCallback(async ({ filePath: fp, projectPath }: { filePath: string, projectPath?: string | undefined }) => {
    async function tryOpenProjectPath(path: string) {
      if (!(await mainApi.pathExists(path))) return false;
      await loadEdlFile({ path, type: 'llc' });
      return true;
    }

    const storeProjectInSourceDir = !storeProjectInWorkingDir;

    async function tryFindAndLoadProjectFile({ chapters, cod }: { chapters: FFprobeChapter[], cod: string | undefined }) {
      try {
        // First try to open from working dir
        if (await tryOpenProjectPath(getEdlFilePath(fp, cod))) return;

        // then try to open project from source file dir
        const sameDirEdlFilePath = getEdlFilePath(fp);
        // MAS only allows fs.access (pathExists) if we don't have access to input dir yet, so check first if the file exists,
        // so we don't need to annoy the user by asking for permission if the project file doesn't exist
        if (await mainApi.pathExists(sameDirEdlFilePath)) {
          // Ok, the file exists. now we have to ask the user, because we need to read that file
          await ensureAccessToSourceDir(fp);
          // Ok, we got access from the user (or already have access), now read the project file
          await loadEdlFile({ path: sameDirEdlFilePath, type: 'llc' });
        }

        // OK, we didn't find a project file, instead maybe try to create project (segments) from chapters
        const edl = tryMapChaptersToEdl(chapters);
        if (edl.length > 0 && (enableImportChapters === 'always' || (enableImportChapters === 'ask' && (await askForImportChapters())))) {
          console.log('Convert chapters to segments', edl);
          loadCutSegments({ segments: edl, append: false });
        }
      } catch (err) {
        if (err instanceof DirectoryAccessDeclinedError) throw err;
        console.error('EDL load failed, but continuing', err);
        errorToast(`${i18n.t('Failed to load segments')} (${err instanceof Error && err.message})`);
      }
    }

    setWorking({ text: i18n.t('Loading file') });
    try {
      // Need to check if file is actually readable
      const pathReadAccessErrorCode = await getPathReadAccessError(fp);
      if (pathReadAccessErrorCode != null) {
        let errorMessage: string | undefined;
        if (pathReadAccessErrorCode === 'ENOENT') errorMessage = i18n.t('The media you tried to open does not exist');
        else if (['EACCES', 'EPERM'].includes(pathReadAccessErrorCode)) errorMessage = i18n.t('You do not have permission to access this file');
        else errorMessage = i18n.t('Could not open media due to error {{errorCode}}', { errorCode: pathReadAccessErrorCode });
        errorToast(errorMessage);
        return;
      }

      // Not sure why this one is needed, but I think sometimes fs.access doesn't fail but it fails when actually trying to read
      if (!(await havePermissionToReadFile(fp))) {
        errorToast(i18n.t('You do not have permission to access this file'));
        return;
      }

      const ffprobeMeta = await readFileFfprobeMeta(fp);
      const fileStats = await readFileStats(fp);
      // console.log('file meta read', fileMeta);

      const fileFormatNew = await getDefaultOutFormat({ filePath: fp, fileMeta: ffprobeMeta });
      if (!fileFormatNew) throw new Error('Unable to determine file format');

      const timecode = autoLoadTimecode ? getTimecodeFromStreams(ffprobeMeta.streams) : undefined;

      const [firstVideoStream] = getRealVideoStreams(ffprobeMeta.streams);
      const [firstAudioStream] = getAudioStreams(ffprobeMeta.streams);

      const copyStreamIdsForPathNew = fromPairs(ffprobeMeta.streams.map((stream) => [
        stream.index, shouldCopyStreamByDefault(stream),
      ]));

      const validDuration = isDurationValid(parseFloat(ffprobeMeta.format.duration));

      const hevcPlaybackSupported = enableNativeHevc && await hevcPlaybackSupportedPromise;

      // need to ensure we have access to write to working directory
      const cod = await ensureWritableOutDir({ inputPath: fp, outDir: customOutDir });

      // if storeProjectInSourceDir is true, we will be writing project file to input path's dir, so ensure that one too
      if (storeProjectInSourceDir) await ensureAccessToSourceDir(fp);

      const existingHtml5FriendlyFile = await findExistingHtml5FriendlyFile(fp, cod);

      const needsAutoHtml5ify = !existingHtml5FriendlyFile && !willPlayerProperlyHandleVideo({ streams: ffprobeMeta.streams, hevcPlaybackSupported, isMasBuild }) && validDuration;

      console.log('loadMedia', { filePath: fp, customOutDir: cod, projectPath });

      // BEGIN STATE UPDATES:

      resetState();
      clearSegColorCounter();

      if (existingHtml5FriendlyFile) {
        console.log('Found existing html5 friendly file', existingHtml5FriendlyFile.path);
        setUsingDummyVideo(existingHtml5FriendlyFile.usingDummyVideo);
        setPreviewFilePath(existingHtml5FriendlyFile.path);
      }

      if (needsAutoHtml5ify) {
        // Try to auto-html5ify if there are known issues with this file
        // 'fastest' works with almost all video files
        await html5ifyAndLoadWithPreferences(cod, fp, 'fastest', firstVideoStream != null, firstAudioStream != null);
      }

      // eslint-disable-next-line unicorn/prefer-ternary
      if (projectPath) {
        await loadEdlFile({ path: projectPath, type: 'llc' });
      } else {
        await tryFindAndLoadProjectFile({ chapters: ffprobeMeta.chapters, cod });
      }

      // throw new Error('test');

      // eslint-disable-next-line no-inner-declarations
      function getFps() {
        if (firstVideoStream != null) return getStreamFps(firstVideoStream);
        if (firstAudioStream != null) return getStreamFps(firstAudioStream);
        return undefined;
      }

      if (timecode) setStartTimeOffset(timecode);
      setDetectedFps(getFps());
      setMainFileMeta({ ffprobeMeta, stats: { size: fileStats.size, atime: fileStats.atimeMs, mtime: fileStats.mtimeMs, ctime: fileStats.ctimeMs, birthtime: fileStats.birthtimeMs } });
      setCopyStreamIdsForPath(fp, () => copyStreamIdsForPathNew);
      setDetectedFileFormat(fileFormatNew);
      if (outFormatLocked) {
        setFileFormat(outFormatLocked);
      } else {
        const recommendedDefaultFormat = mapRecommendedDefaultFormat({ sourceFormat: fileFormatNew, streams: ffprobeMeta.streams });
        if (recommendedDefaultFormat.message) showNotification({ icon: 'info', text: recommendedDefaultFormat.message });
        setFileFormat(recommendedDefaultFormat.format);
      }

      // only show one toast, or else we will only show the last one
      if (existingHtml5FriendlyFile && !existingHtml5FriendlyFile.usingDummyVideo) {
        showPreviewFileLoadedMessage(basename(existingHtml5FriendlyFile.path));
      } else if (needsAutoHtml5ify) {
        showNotNativelySupportedMessage();
      } else if (isAudioDefinitelyNotSupported(ffprobeMeta.streams)) {
        showNotification({ icon: 'info', text: i18n.t('The audio track is not supported while previewing. You can convert to a supported format from the menu') });
      } else if (!validDuration) {
        getSwal().toast.fire({ icon: 'warning', timer: 10000, text: i18n.t('This file does not have a valid duration. This may cause issues. You can try to fix the file\'s duration from the File menu') });
      }

      // This needs to be last, because it triggers <video> to load the video
      // If not, onVideoError might be triggered before setWorking() has been cleared.
      // https://github.com/mifi/lossless-cut/issues/515
      setFilePath(fp);
    } catch (err) {
      if (err instanceof DirectoryAccessDeclinedError) return;
      resetState();
      throw err;
    }
  }, [storeProjectInWorkingDir, setWorking, loadEdlFile, getEdlFilePath, enableImportChapters, ensureAccessToSourceDir, loadCutSegments, autoLoadTimecode, enableNativeHevc, ensureWritableOutDir, customOutDir, resetState, clearSegColorCounter, setCopyStreamIdsForPath, setDetectedFileFormat, outFormatLocked, setUsingDummyVideo, setPreviewFilePath, html5ifyAndLoadWithPreferences, setFileFormat, showNotification, showPreviewFileLoadedMessage, showNotNativelySupportedMessage]);

  const toggleLastCommands = useCallback(() => setLastCommandsVisible((val) => !val), []);
  const toggleSettings = useCallback(() => setSettingsVisible((val) => !val), []);

  const seekClosestKeyframe = useCallback((direction: number) => {
    const sigma = detectedFps ? (1 / detectedFps) : 0.1; // because we don't want it to find the keyframe we're currently at.
    const time = findNearestKeyFrameTime({ time: getRelevantTime() + direction * sigma, direction });
    if (time == null) return;
    seekAbs(time);
  }, [detectedFps, findNearestKeyFrameTime, getRelevantTime, seekAbs]);

  const onTimelineWheel = useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, invertTimelineScroll, zoomRel, seekRel, shortStep, seekClosestKeyframe });

  const seekAccelerationRef = useRef(1);

  const userOpenSingleFile = useCallback(async ({ path: pathIn, isLlcProject }: { path: string, isLlcProject?: boolean }) => {
    let path = pathIn;
    let projectPath: string | undefined;

    // Open .llc AND media referenced within
    if (isLlcProject) {
      console.log('Loading LLC project', path);
      const project = await loadLlcProject(path);
      const { mediaFileName } = project;

      console.log({ mediaFileName });
      if (!mediaFileName) return;

      const mediaFilePath = pathJoin(dirname(path), mediaFileName);

      // Note: MAS doesn't allow anything else than fs.access before the user has granted our app access to input dir
      if (!(await mainApi.pathExists(mediaFilePath))) {
        errorToast(i18n.t('The media file referenced by the project file you tried to open does not exist in the same directory as the project file: {{mediaFileName}}', { mediaFileName }));
        return;
      }

      projectPath = path;

      // We might need to get user's access to the project file's directory, in order to read the media file
      try {
        await ensureAccessToSourceDir(mediaFilePath);
      } catch (err) {
        if (err instanceof DirectoryAccessDeclinedError) return;
      }
      path = mediaFilePath;
    }

    if (/\.vob$/i.test(path) && mustDisallowVob()) return;

    await loadMedia({ filePath: path, projectPath });
  }, [ensureAccessToSourceDir, loadMedia]);

  // todo merge with userOpenFiles?
  const batchOpenSingleFile = useCallback(async (path: string) => {
    if (workingRef.current) return;
    if (filePath === path) return;
    setWorking({ text: i18n.t('Loading file') });
    try {
      await withErrorHandling(async () => {
        await userOpenSingleFile({ path });
      }, i18n.t('Failed to open file'));
    } finally {
      setWorking(undefined);
    }
  }, [workingRef, filePath, setWorking, withErrorHandling, userOpenSingleFile]);

  const batchFileJump = useCallback((direction: number, alsoOpen: boolean) => {
    if (batchFiles.length === 0) return;

    let newSelectedBatchFiles: [string];
    if (selectedBatchFiles.length === 0) {
      newSelectedBatchFiles = [batchFiles[0]!.path];
    } else {
      const selectedFilePath = selectedBatchFiles[direction > 0 ? selectedBatchFiles.length - 1 : 0];
      const pathIndex = batchFiles.findIndex(({ path }) => path === selectedFilePath);
      if (pathIndex === -1) return;
      const nextFile = batchFiles[pathIndex + direction];
      if (!nextFile) return;
      newSelectedBatchFiles = [nextFile.path];
    }

    setSelectedBatchFiles(newSelectedBatchFiles);
    if (alsoOpen) batchOpenSingleFile(newSelectedBatchFiles[0]);
  }, [batchFiles, batchOpenSingleFile, selectedBatchFiles]);

  const batchOpenSelectedFile = useCallback(() => {
    const [firstSelectedBatchFile] = selectedBatchFiles;
    if (firstSelectedBatchFile == null) return;
    batchOpenSingleFile(firstSelectedBatchFile);
  }, [batchOpenSingleFile, selectedBatchFiles]);

  const onBatchFileSelect = useCallback((path: string) => {
    if (selectedBatchFiles.includes(path)) batchOpenSingleFile(path);
    else setSelectedBatchFiles([path]);
  }, [batchOpenSingleFile, selectedBatchFiles]);

  const goToTimecode = useCallback(async () => {
    if (!filePath) return;
    const timecode = await promptTimecode({
      initialValue: formatTimecode({ seconds: commandedTimeRef.current }),
      title: i18n.t('Seek to timecode'),
      description: i18n.t('Use + and - for relative seek'),
      allowRelative: true,
      inputPlaceholder: timecodePlaceholder,
    });

    if (timecode === undefined) return;

    if (timecode.relDirection != null) seekRel(timecode.duration * timecode.relDirection);
    else seekAbs(timecode.duration);
  }, [filePath, promptTimecode, formatTimecode, commandedTimeRef, timecodePlaceholder, seekRel, seekAbs]);

  const goToTimecodeDirect = useCallback(async ({ time: timeStr }: { time: string }) => {
    if (!filePath) return;
    invariant(timeStr != null);
    const timecode = parseTimecode(timeStr);
    invariant(timecode != null);
    seekAbs(timecode);
  }, [filePath, parseTimecode, seekAbs]);

  const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);

  const handleShowStreamsSelectorClick = useCallback(() => {
    setStreamsSelectorShown(true);
  }, []);

  const extractAllStreams = useCallback(async () => {
    if (!filePath) return;

    if (!(await confirmDialog({ focusConfirm: true, description: t('Please confirm that you want to extract all tracks as separate files'), confirmButtonText: t('Extract all tracks') }))) return;

    if (workingRef.current) return;
    try {
      setWorking({ text: i18n.t('Extracting all streams') });
      setStreamsSelectorShown(false);
      const [firstExtractedPath] = await extractStreams({ customOutDir, streams: mainCopiedStreams });
      if (!hideAllNotifications && firstExtractedPath != null) {
        showOsNotification(i18n.t('All tracks have been extracted'));
        openExportFinishedDialog({ filePath: firstExtractedPath, children: i18n.t('All streams have been extracted as separate files') });
      }
    } catch (err) {
      showOsNotification(i18n.t('Failed to extract tracks'));

      if (err instanceof RefuseOverwriteError) {
        showRefuseToOverwrite();
      } else {
        errorToast(i18n.t('Failed to extract all streams'));
        console.error('Failed to extract all streams', err);
      }
    } finally {
      setWorking(undefined);
    }
  }, [confirmDialog, customOutDir, extractStreams, filePath, hideAllNotifications, mainCopiedStreams, openExportFinishedDialog, setWorking, showOsNotification, t, workingRef]);


  const askStartTimeOffset = useCallback(async () => {
    const newStartTimeOffset = await promptTimecode({
      initialValue: startTimeOffset !== undefined ? formatTimecode({ seconds: startTimeOffset }) : undefined,
      title: i18n.t('Set custom start time offset'),
      description: i18n.t('Instead of video apparently starting at 0, you can offset by a specified value. This only applies to the preview inside LosslessCut and does not modify the file in any way. (Useful for viewing/cutting videos according to timecodes)'),
      inputPlaceholder: timecodePlaceholder,
      allowRelative: true,
    });

    if (newStartTimeOffset === undefined || newStartTimeOffset.duration < 0) return;

    const duration = newStartTimeOffset.relDirection != null ? newStartTimeOffset.duration * newStartTimeOffset.relDirection : newStartTimeOffset.duration;
    setStartTimeOffset(duration);
  }, [formatTimecode, promptTimecode, startTimeOffset, timecodePlaceholder]);

  const toggleKeyboardShortcuts = useCallback(() => setKeyboardShortcutsVisible((v) => !v), []);

  const runAndReloadFile = useCallback(async ({ operation, loadingText, errorText = i18n.t('The operation failed'), nameSuffix }: { operation: (params: { filePath: string, outPath: string }) => Promise<string>, loadingText: string, errorText?: string, nameSuffix: string }) => {
    if (!checkFileOpened() || workingRef.current) return;
    try {
      setWorking({ text: loadingText });
      setProgress(0);
      await withErrorHandling(async () => {
        invariant(fileFormat != null);
        invariant(filePath != null);
        const ext = getOutFileExtension({ outFormat: fileFormat, filePath });
        const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `${nameSuffix}${ext}` });
        const newPath = await operation({ filePath, outPath });
        await transferTimestamps({ inPath: filePath, outPath, duration: undefined, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart });

        await loadMedia({ filePath: newPath });
      }, errorText);
    } finally {
      setWorking(undefined);
      setProgress(undefined);
    }
  }, [checkFileOpened, customOutDir, fileFormat, filePath, loadMedia, setWorking, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, withErrorHandling, workingRef]);

  const tryFixInvalidDuration = useCallback(async () => runAndReloadFile({
    operation: async ({ filePath: fp, outPath }) => {
      const path = await fixInvalidDuration({ filePath: fp, outPath, onProgress: setProgress });
      showNotification({ icon: 'info', text: i18n.t('Duration has been fixed') });
      return path;
    },
    loadingText: i18n.t('Fixing file duration'),
    nameSuffix: 'reformatted',
  }), [fixInvalidDuration, runAndReloadFile, showNotification]);

  const tryDecimate = useCallback(async () => {
    if (!checkFileOpened()) return;
    const params = await openDecimateDialog();
    if (params == null) return;
    await runAndReloadFile({
      operation: async ({ filePath: fp, outPath }) => decimate({ filePath: fp, outPath, ...params }),
      loadingText: i18n.t('Decimating video'),
      nameSuffix: 'decimated',
    });
  }, [checkFileOpened, decimate, openDecimateDialog, runAndReloadFile]);

  const addStreamSourceFile = useCallback(async (path: string) => {
    if (allFilesMeta[path]) return undefined; // Already added?
    const fileMeta = await readFileFfprobeMeta(path);
    // console.log('streams', fileMeta.streams);
    setExternalFilesMeta((old) => ({ ...old, [path]: fileMeta }));
    setCopyStreamIdsForPath(path, () => fromPairs(fileMeta.streams.map(({ index }) => [index, true])));
    return fileMeta;
  }, [allFilesMeta, setCopyStreamIdsForPath]);

  const updateStreamParams = useCallback<Parameters<typeof StreamsSelector>[0]['updateStreamParams']>((fileId, streamId, setter) => setParamsByFile(produce((draft) => {
    if (!draft.has(fileId)) draft.set(fileId, { metadata: {}, paramsByStream: new Map() });
    const fileMap = draft.get(fileId);
    invariant(fileMap != null);
    if (!fileMap.paramsByStream.has(streamId)) fileMap.paramsByStream.set(streamId, { metadata: {} });

    const params = fileMap.paramsByStream.get(streamId);
    invariant(params != null);
    setter(params);
  })), [setParamsByFile]);

  const captureSnapshotAsCoverArt = useCallback(async () => {
    if (!filePath) return;
    await withErrorHandling(async () => {
      const currentTime = getRelevantTime();
      const path = await captureFrameFromFfmpeg({ customOutDir, filePath, time: currentTime, captureFormat, quality: captureFrameQuality });
      const fileMeta = await addStreamSourceFile(path);
      if (!fileMeta) return;
      const firstIndex = fileMeta.streams[0]!.index;
      // eslint-disable-next-line no-param-reassign
      updateStreamParams(path, firstIndex, (params) => { params.disposition = 'attached_pic'; });
      showNotification({ text: i18n.t('Current frame has been set as cover art') });
    }, i18n.t('Failed to capture frame'));
  }, [addStreamSourceFile, captureFormat, captureFrameFromFfmpeg, captureFrameQuality, customOutDir, filePath, getRelevantTime, showNotification, updateStreamParams, withErrorHandling]);

  const batchLoadPaths = useCallback((newPaths: string[], append?: boolean) => {
    setBatchFiles((existingFiles) => {
      const mapPathsToFiles = (paths: string[]) => paths.map((path) => ({ path, name: basename(path) }));
      if (append) {
        const newUniquePaths = newPaths.filter((newPath) => !existingFiles.some(({ path: existingPath }) => newPath === existingPath));
        const [firstNewUniquePath] = newUniquePaths;
        if (firstNewUniquePath == null) return existingFiles;
        setSelectedBatchFiles([firstNewUniquePath]);
        return [...existingFiles, ...mapPathsToFiles(newUniquePaths)];
      }
      const [firstNewPath] = newPaths;
      invariant(firstNewPath != null);
      setSelectedBatchFiles([firstNewPath]);
      return mapPathsToFiles(newPaths);
    });
  }, []);

  const userOpenFiles = useCallback(async (newFilePathsIn?: string[]) => {
    await withErrorHandling(async () => {
      let newFilePaths = newFilePathsIn;
      if (!newFilePaths || newFilePaths.length === 0) return;

      console.log('userOpenFiles');
      console.log(newFilePaths.join('\n'));

      lastOpenedPathRef.current = newFilePaths[0]!;

      let firstNewFilePath = newFilePaths[0]!;

      // first check if it is a single directory, and if so, read it recursively
      if (newFilePaths.length === 1 && (await lstat(firstNewFilePath)).isDirectory()) {
        console.log('Reading directory...');
        invariant(firstNewFilePath != null);
        newFilePaths = await readDirRecursively(firstNewFilePath);
      }

      // Only allow opening regular files
      for (const path of newFilePaths) {
        const fileStat = await lstat(path);

        if (!fileStat.isFile()) {
          errorToast(i18n.t('Cannot open anything else than regular files'));
          console.warn('Not a file:', path);
          return;
        }
      }

      if (newFilePaths.length > 1 && alwaysConcatMultipleFiles) {
        batchLoadPaths(newFilePaths);
        setConcatDialogOpen(true);
        return;
      }

      firstNewFilePath = newFilePaths[0]!;
      invariant(firstNewFilePath != null);

      // https://en.wikibooks.org/wiki/Inside_DVD-Video/Directory_Structure
      if (newFilePaths.length === 1 && /^video_ts$/i.test(basename(firstNewFilePath))) {
        if (mustDisallowVob()) return;
        newFilePaths = await readVideoTs(firstNewFilePath);
      }

      if (workingRef.current) return;
      try {
        setWorking({ text: i18n.t('Loading file') });

        // If it's a project file (not llc) and we have an already opened file, import segments from the project
        const matchingImportProjectType = getImportProjectType(firstNewFilePath);
        if (matchingImportProjectType) {
          if (!checkFileOpened()) return;
          await loadEdlFile({ path: firstNewFilePath, type: matchingImportProjectType, append: true });
          return;
        }

        const filePathLowerCase = firstNewFilePath.toLowerCase();
        const isLlcProject = filePathLowerCase.endsWith('.llc');

        // Need to ask the user what to do if more than one option
        const inputOptions: { open?: string, project?: string, tracks?: string, subtitles?: string, addToBatch?: string, mergeWithCurrentFile?: string } = {};

        if (newFilePaths.length === 1) {
          inputOptions.open = isFileOpened ? i18n.t('Open the file instead of the current one') : i18n.t('Open the file');
        }

        if (isFileOpened && newFilePaths.length === 1) {
          if (isLlcProject) inputOptions.project = i18n.t('Load segments from the new file, but keep the current media');
          else if (filePathLowerCase.endsWith('.srt')) inputOptions.subtitles = i18n.t('Convert subtitiles into segments');
          inputOptions.tracks = i18n.t('Include all tracks from the new file');
        }

        if (isFileOpened) inputOptions.mergeWithCurrentFile = i18n.t('Merge/concatenate with current file');
        if (batchFiles.length > 0 || newFilePaths.length > 1) inputOptions.addToBatch = i18n.t('Add the file to the batch list');

        const inputOptionsKeys = Object.keys(inputOptions) as (keyof typeof inputOptions)[];

        let openFileResponse: OpenFileResponse | undefined;
        if (inputOptionsKeys.length === 1) [openFileResponse] = inputOptionsKeys;
        if (!enableAskForFileOpenAction && inputOptionsKeys.length > 1) openFileResponse = 'addToBatch';
        if (enableAskForFileOpenAction && inputOptionsKeys.length > 1) openFileResponse = await askForFileOpenAction(Object.entries(inputOptions) as [OpenFileResponse, string][]);
        else if (newFilePaths.length === 1) openFileResponse = 'open';

        if (openFileResponse === 'open') {
          await userOpenSingleFile({ path: firstNewFilePath, isLlcProject });
          return;
        }
        if (openFileResponse === 'project') {
          await loadEdlFile({ path: firstNewFilePath, type: 'llc' });
          return;
        }
        if (openFileResponse === 'subtitles') {
          await loadEdlFile({ path: firstNewFilePath, type: 'srt' });
          return;
        }
        if (openFileResponse === 'tracks') {
          await addStreamSourceFile(firstNewFilePath);
          setStreamsSelectorShown(true);
          return;
        }
        if (openFileResponse === 'addToBatch') {
          batchLoadPaths(newFilePaths, true);
          return;
        }
        if (openFileResponse === 'mergeWithCurrentFile') {
          const batchPaths = new Set<string>();
          if (filePath) batchPaths.add(filePath);
          newFilePaths.forEach((path) => batchPaths.add(path));
          batchLoadPaths([...batchPaths]);
          if (batchPaths.size > 1) setConcatDialogOpen(true);
        }
        // else: no match means dialog canceled or nothing useful to do:
      } finally {
        setWorking(undefined);
      }
    }, i18n.t('Failed to open file'));
  }, [withErrorHandling, alwaysConcatMultipleFiles, workingRef, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, enableAskForFileOpenAction, checkFileOpened, loadEdlFile, userOpenSingleFile, addStreamSourceFile, filePath]);

  const openFilesDialog = useCallback(async () => {
    // On Windows and Linux an open dialog can not be both a file selector and a directory selector, so if you set `properties` to `['openFile', 'openDirectory']` on these platforms, a directory selector will be shown. #1995
    const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile', 'multiSelections'], defaultPath: lastOpenedPathRef.current!, title: t('Open file') });
    if (canceled) return;
    userOpenFiles(filePaths);
  }, [t, userOpenFiles]);

  const openDirDialog = useCallback(async () => {
    const { canceled, filePaths } = await showOpenDialog({ properties: ['openDirectory', 'multiSelections'], defaultPath: lastOpenedPathRef.current!, title: t('Open folder') });
    if (canceled) return;
    userOpenFiles(filePaths);
  }, [t, userOpenFiles]);

  const concatBatch = useCallback(() => {
    if (batchFiles.length < 2) {
      openFilesDialog();
      return;
    }

    setConcatDialogOpen(true);
  }, [batchFiles.length, openFilesDialog]);

  const togglePlaySelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: false, requestPlaybackMode: 'play-selected-segments' }), [togglePlay]);
  const toggleLoopSelectedSegments = useCallback(() => togglePlay({ resetPlaybackRate: false, requestPlaybackMode: 'loop-selected-segments' }), [togglePlay]);

  const copySegmentsToClipboard = useCallback(async () => {
    if (!isFileOpened || selectedSegments.length === 0) return;
    mainApi.writeClipboardText(formatTsvHuman(selectedSegments));
  }, [isFileOpened, selectedSegments]);

  const showIncludeExternalStreamsDialog = useCallback(async () => {
    await withErrorHandling(async () => {
      const { canceled, filePaths } = await showOpenDialog({ properties: ['openFile'], title: t('Include more tracks from other file') });
      const [firstFilePath] = filePaths;
      if (canceled || firstFilePath == null) return;
      await addStreamSourceFile(firstFilePath);
    }, i18n.t('Failed to include track'));
  }, [addStreamSourceFile, t, withErrorHandling]);

  const toggleFullscreenVideo = useCallback(async () => {
    if (!screenfull.isEnabled) {
      console.warn('Fullscreen not allowed');
      return;
    }
    try {
      if (videoRef.current == null) {
        console.warn('No video tag to full screen');
        return;
      }
      invariant(videoContainerRef.current != null);
      await screenfull.toggle(videoContainerRef.current, { navigationUI: 'hide' });
    } catch (err) {
      console.error('Failed to toggle fullscreen', err);
    }
  }, [videoContainerRef, videoRef]);

  const onEditSegmentTags = useCallback((index: number) => {
    setEditingSegmentTagsSegmentIndex(index);
    const seg = cutSegments[index];
    invariant(seg != null);
    setEditingSegmentTags(getSegmentTags(seg));
  }, [cutSegments]);

  const editCurrentSegmentTags = useCallback(() => {
    onEditSegmentTags(currentSegIndexSafe);
  }, [currentSegIndexSafe, onEditSegmentTags]);

  const promptDownloadMediaUrlWrapper = useCallback(async () => {
    try {
      setWorking({ text: t('Downloading URL') });
      await withErrorHandling(async () => {
        const newCustomOutDir = await ensureWritableOutDir({ outDir: customOutDir });
        if (newCustomOutDir == null) {
          errorToast(i18n.t('Please select a working directory first'));
          return;
        }
        const outPath = getDownloadMediaOutPath(newCustomOutDir, `downloaded-media-${Date.now()}.mkv`);
        const downloaded = await promptDownloadMediaUrl(outPath);
        if (downloaded) await loadMedia({ filePath: outPath });
      }, i18n.t('Failed to download URL'));
    } finally {
      setWorking();
    }
  }, [customOutDir, ensureWritableOutDir, loadMedia, setWorking, t, withErrorHandling]);

  type MainKeyboardAction = Exclude<KeyboardAction, 'goToTimecodeDirect'>;

  const mainActions = useMemo(() => {
    async function exportYouTube() {
      if (!checkFileOpened()) return;
      await openYouTubeChaptersDialog(formatYouTube(cutSegments));
    }

    function seekRelAccelerated(amount: number) {
      seekRel(seekAccelerationRef.current * amount);
      seekAccelerationRef.current *= keyboardSeekAccFactor;
    }

    const ret: Record<MainKeyboardAction, () => void> = {
      // NOTE: Do not change these keys because users have bound keys by these names in their config files
      // For actions, see also KeyboardShortcuts.jsx
      togglePlayNoResetSpeed: () => togglePlay(),
      togglePlayResetSpeed: () => togglePlay({ resetPlaybackRate: true }),
      togglePlayOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'play-segment-once' }),
      toggleLoopOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-segment' }),
      toggleLoopStartEndOnlyCurrentSegment: () => togglePlay({ resetPlaybackRate: true, requestPlaybackMode: 'loop-segment-start-end' }),
      togglePlaySelectedSegments,
      toggleLoopSelectedSegments,
      play: () => play(),
      pause,
      reducePlaybackRate: () => userChangePlaybackRate(-1),
      reducePlaybackRateMore: () => userChangePlaybackRate(-1, 2),
      increasePlaybackRate: () => userChangePlaybackRate(1),
      increasePlaybackRateMore: () => userChangePlaybackRate(1, 2),
      timelineToggleComfortZoom,
      captureSnapshot,
      captureSnapshotAsCoverArt,
      captureSnapshotToClipboard,
      setCutStart,
      setCutEnd,
      cleanupFilesDialog,
      splitCurrentSegment,
      focusSegmentAtCursor,
      selectSegmentsAtCursor,
      increaseRotation,
      goToTimecode: () => goToTimecode(),
      seekBackwards: () => seekRelAccelerated(-1 * keyboardNormalSeekSpeed),
      seekBackwards2: () => seekRelAccelerated(-1 * keyboardSeekSpeed2),
      seekBackwards3: () => seekRelAccelerated(-1 * keyboardSeekSpeed3),
      seekForwards: () => seekRelAccelerated(keyboardNormalSeekSpeed),
      seekForwards2: () => seekRelAccelerated(keyboardSeekSpeed2),
      seekForwards3: () => seekRelAccelerated(keyboardSeekSpeed3),
      seekBackwardsPercent: () => seekRelPercent(-0.01),
      seekForwardsPercent: () => seekRelPercent(0.01),
      seekBackwardsKeyframe: () => seekClosestKeyframe(-1),
      seekForwardsKeyframe: () => seekClosestKeyframe(1),
      seekPreviousFrame: () => shortStep(-1),
      seekNextFrame: () => shortStep(1),
      jumpPrevSegment: () => jumpSeg({ rel: -1 }),
      jumpSeekPrevSegment: () => jumpSeg({ rel: -1, seek: true }),
      jumpNextSegment: () => jumpSeg({ rel: 1 }),
      jumpSeekNextSegment: () => jumpSeg({ rel: 1, seek: true }),
      jumpFirstSegment: () => jumpSeg({ abs: 0 }),
      jumpSeekFirstSegment: () => jumpSeg({ abs: 0, seek: true }),
      jumpLastSegment: () => jumpSeg({ abs: cutSegments.length - 1 }),
      jumpSeekLastSegment: () => jumpSeg({ abs: cutSegments.length - 1, seek: true }),
      jumpCutStart,
      jumpCutEnd,
      jumpTimelineStart,
      jumpTimelineEnd,
      timelineZoomIn: () => zoomRel(1),
      timelineZoomOut: () => zoomRel(-1),
      batchPreviousFile: () => batchFileJump(-1, false),
      batchNextFile: () => batchFileJump(1, false),
      batchOpenPreviousFile: () => batchFileJump(-1, true),
      batchOpenNextFile: () => batchFileJump(1, true),
      batchOpenSelectedFile,
      closeBatch,
      removeCurrentSegment: () => removeSegment(currentSegIndexSafe, true),
      removeCurrentCutpoint: () => removeSegment(currentSegIndexSafe),
      undo: () => cutSegmentsHistory.back(),
      redo: () => cutSegmentsHistory.forward(),
      labelCurrentSegment: () => labelSegment(currentSegIndexSafe),
      addSegment,
      duplicateCurrentSegment,
      toggleLastCommands,
      export: () => onExportPress(),
      extractCurrentSegmentFramesAsImages,
      extractSelectedSegmentsFramesAsImages,
      reorderSegsByStartTime,
      invertAllSegments,
      fillSegmentsGaps,
      combineOverlappingSegments,
      combineSelectedSegments,
      createFixedDurationSegments,
      createNumSegments,
      createFixedByteSizedSegments,
      createRandomSegments,
      alignSegmentTimesToKeyframes,
      shuffleSegments,
      clearSegments,
      toggleSegmentsList,
      toggleStreamsSelector,
      extractAllStreams,
      convertFormatCurrentFile: () => userHtml5ifyCurrentFile(),
      convertFormatBatch,
      concatBatch,
      toggleKeyframeCutMode: () => toggleKeyframeCut(true),
      toggleCaptureFormat,
      toggleStripAudio,
      toggleStripVideo,
      toggleStripSubtitle,
      toggleStripThumbnail,
      toggleStripCurrentFilter: () => applyEnabledStreamsFilter(),
      toggleStripAll,
      toggleDarkMode,
      setStartTimeOffset: askStartTimeOffset,
      deselectAllSegments,
      selectAllSegments,
      selectOnlyCurrentSegment,
      editCurrentSegmentTags,
      toggleCurrentSegmentSelected,
      invertSelectedSegments,
      removeSelectedSegments,
      fixInvalidDuration: tryFixInvalidDuration,
      decimate: tryDecimate,
      shiftAllSegmentTimes,
      increaseVolume: () => setPlaybackVolume((val) => Math.min(1, val + 0.07)),
      decreaseVolume: () => setPlaybackVolume((val) => Math.max(0, val - 0.07)),
      makeCursorTimeZero: () => setStartTimeOffset(-commandedTimeRef.current),
      toggleMuted,
      copySegmentsToClipboard,
      reloadFile: () => setCacheBuster((v) => v + 1),
      quit: () => mainApi.quitApp(),
      closeCurrentFile: () => { closeFileWithConfirm(); },
      exportYouTube,
      showStreamsSelector: handleShowStreamsSelectorClick,
      html5ify: () => userHtml5ifyCurrentFile({ ignoreRememberedValue: true }),
      openFilesDialog,
      openDirDialog,
      toggleSettings,
      openSendReportDialog: () => { openSendReportDialogWithState(); },
      detectBlackScenes,
      detectSilentScenes,
      detectSceneChanges,
      readAllKeyframes,
      createSegmentsFromKeyframes,
      toggleWaveformMode,
      toggleShowThumbnails,
      toggleShowKeyframes,
      showIncludeExternalStreamsDialog,
      toggleFullscreenVideo,
      selectAllMarkers,
      selectSegmentsByLabel,
      selectSegmentsByExpr,
      labelSelectedSegments,
      mutateSegmentsByExpr,

      // also called from menu (note: no longer a toggle. Esc must be used to close it because it's a dialog now):
      toggleKeyboardShortcuts,
      generateOverviewWaveform,
    };

    return ret;
  }, [togglePlaySelectedSegments, toggleLoopSelectedSegments, pause, timelineToggleComfortZoom, captureSnapshot, captureSnapshotAsCoverArt, captureSnapshotToClipboard, setCutStart, setCutEnd, cleanupFilesDialog, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, increaseRotation, jumpCutStart, jumpCutEnd, jumpTimelineStart, jumpTimelineEnd, batchOpenSelectedFile, closeBatch, addSegment, duplicateCurrentSegment, toggleLastCommands, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, reorderSegsByStartTime, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, createFixedDurationSegments, createNumSegments, createFixedByteSizedSegments, createRandomSegments, alignSegmentTimesToKeyframes, shuffleSegments, clearSegments, toggleSegmentsList, toggleStreamsSelector, extractAllStreams, convertFormatBatch, concatBatch, toggleCaptureFormat, toggleStripAudio, toggleStripVideo, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, toggleDarkMode, askStartTimeOffset, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, editCurrentSegmentTags, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, tryFixInvalidDuration, tryDecimate, shiftAllSegmentTimes, toggleMuted, copySegmentsToClipboard, handleShowStreamsSelectorClick, openFilesDialog, openDirDialog, toggleSettings, detectBlackScenes, detectSilentScenes, detectSceneChanges, readAllKeyframes, createSegmentsFromKeyframes, toggleWaveformMode, toggleShowThumbnails, toggleShowKeyframes, showIncludeExternalStreamsDialog, toggleFullscreenVideo, selectAllMarkers, selectSegmentsByLabel, selectSegmentsByExpr, labelSelectedSegments, mutateSegmentsByExpr, toggleKeyboardShortcuts, generateOverviewWaveform, checkFileOpened, cutSegments, seekRel, keyboardSeekAccFactor, togglePlay, play, userChangePlaybackRate, goToTimecode, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, seekRelPercent, seekClosestKeyframe, shortStep, jumpSeg, zoomRel, batchFileJump, removeSegment, currentSegIndexSafe, cutSegmentsHistory, labelSegment, onExportPress, userHtml5ifyCurrentFile, toggleKeyframeCut, applyEnabledStreamsFilter, setPlaybackVolume, commandedTimeRef, closeFileWithConfirm, openSendReportDialogWithState]);

  const getKeyboardAction = useCallback((action: MainKeyboardAction) => mainActions[action], [mainActions]);

  const keyUpActions = useMemo(() => {
    function seekReset() {
      seekAccelerationRef.current = 1;
    }
    return {
      seekBackwards: () => seekReset(),
      seekBackwards2: () => seekReset(),
      seekBackwards3: () => seekReset(),
      seekForwards: () => seekReset(),
      seekForwards2: () => seekReset(),
      seekForwards3: () => seekReset(),
    };
  }, []);

  const { keyboardLayoutMap, updateKeyboardLayout } = useKeyboard({ keyBindings, keyUpActions, getKeyboardAction, closeExportConfirm, exportConfirmOpen });

  useEffect(() => {
    // eslint-disable-next-line unicorn/prefer-add-event-listener
    document.ondragover = dragPreventer;
    // eslint-disable-next-line unicorn/prefer-add-event-listener
    document.ondragend = dragPreventer;

    ipcRenderer.send('renderer-ready');
  }, []);

  useEffect(() => {
    ipcRenderer.send('setAskBeforeClose', askBeforeClose && isFileOpened);
  }, [askBeforeClose, isFileOpened]);

  const extractSingleStream = useCallback(async (index: number) => {
    if (!filePath) return;

    if (workingRef.current) return;
    try {
      setWorking({ text: i18n.t('Extracting track') });
      // setStreamsSelectorShown(false);
      const [firstExtractedPath] = await extractStreams({ customOutDir, streams: mainStreams.filter((s) => s.index === index) });
      if (!hideAllNotifications && firstExtractedPath != null) {
        showOsNotification(i18n.t('Track has been extracted'));
        openExportFinishedDialog({ filePath: firstExtractedPath, children: i18n.t('Track has been extracted') });
      }
    } catch (err) {
      showOsNotification(i18n.t('Failed to extract track'));

      if (err instanceof RefuseOverwriteError) {
        showRefuseToOverwrite();
      } else {
        errorToast(i18n.t('Failed to extract track'));
        console.error('Failed to extract track', err);
      }
    } finally {
      setWorking(undefined);
    }
  }, [customOutDir, extractStreams, filePath, hideAllNotifications, mainStreams, openExportFinishedDialog, setWorking, showOsNotification, workingRef]);

  const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);

  const onVideoError = useCallback(async () => {
    const error = videoRef.current?.error;
    if (!error) return;

    console.error('onVideoError', error.message, error.code);

    try {
      const PIPELINE_ERROR_READ = 2; // e.g. file has been moved after opening https://github.com/mifi/lossless-cut/issues/2423
      const PIPELINE_ERROR_DECODE = 3; // This usually happens when the user presses play or seeks, but the video is not actually playable. To reproduce: "RX100VII PCM audio timecode.MP4" or see https://github.com/mifi/lossless-cut/issues/804
      const MEDIA_ERR_SRC_NOT_SUPPORTED = 4; // Test: issue-668-3.20.1.m2ts - NOTE: DEMUXER_ERROR_COULD_NOT_OPEN and DEMUXER_ERROR_NO_SUPPORTED_STREAMS is also 4

      if (
        (
          (
            // MEDIA_ERR_SRC_NOT_SUPPORTED generally means we need to convert to supported format,
            error.code === MEDIA_ERR_SRC_NOT_SUPPORTED
            // _however_ this error can also happen half way into playback if the file has some corruption
            // but in that case we also get: "DEMUXER_ERROR_COULD_NOT_PARSE: FFmpegDemuxer: PTS is not defined 4"
            // and we don't want to auto convert in that case:
            && !error.message?.startsWith('DEMUXER_ERROR_COULD_NOT_PARSE')
          )
          || error.code === PIPELINE_ERROR_DECODE
        )
        && !usingPreviewFile // if we are already using preview file, we shouldn't try to do it again
        && filePath
        && !(error.code === MEDIA_ERR_SRC_NOT_SUPPORTED && error.message?.startsWith('DEMUXER_ERROR_COULD_NOT_PARSE'))
      ) {
        if (workingRef.current) return;
        try {
          setWorking({ text: i18n.t('Converting to supported format') });

          console.log('Trying to convert to supported format');

          // A valid duration is needed to create a html5ified dummy (`fastest`).
          if (!isDurationValid(parseFfprobeDuration(mainFileFormat?.duration))) {
            throw new UserFacingError(i18n.t('Invalid duration'));
          }

          if (hasVideo || hasAudio) {
            await html5ifyAndLoadWithPreferences(customOutDir, filePath, 'fastest', hasVideo, hasAudio);
            showNotNativelySupportedMessage();
          }
        } catch (err) {
          if (err instanceof UserFacingError) {
            throw err;
          }
          console.error(err);
          showPlaybackFailedMessage();
        } finally {
          setWorking(undefined);
        }
      } else if (error.code === PIPELINE_ERROR_READ) { // file is not readable or was removed
        getSwal().toast.fire({ icon: 'error', timer: 10000, text: i18n.t('Failed to read file. Perhaps it has been moved?') });
      }
    } catch (err) {
      toastError(err);
    }
  }, [videoRef, usingPreviewFile, filePath, workingRef, setWorking, mainFileFormat?.duration, hasVideo, hasAudio, html5ifyAndLoadWithPreferences, customOutDir, showNotNativelySupportedMessage]);

  const onVideoFocus = useCallback<FocusEventHandler<HTMLVideoElement>>((e) => {
    // prevent video element from stealing focus in fullscreen mode https://github.com/mifi/lossless-cut/issues/543#issuecomment-1868167775
    e.target.blur();
  }, []);

  const onVideoClick = useCallback(() => togglePlay(), [togglePlay]);

  const tryExportEdlFile = useCallback(async (type: EdlExportType) => {
    if (!checkFileOpened() || selectedSegments.length === 0) return;
    await withErrorHandling(async () => {
      await exportEdlFile({ type, cutSegments: selectedSegments, customOutDir, filePath, getFrameCount });
    }, i18n.t('Failed to export project'));
  }, [checkFileOpened, customOutDir, filePath, getFrameCount, selectedSegments, withErrorHandling]);

  const importEdlFile = useCallback(async (type: EdlImportType) => {
    if (!checkFileOpened()) return;

    await withErrorHandling(async () => {
      const edl = await askForEdlImport({ type, fps: detectedFps, fileDuration });
      if (edl.length > 0) loadCutSegments({ segments: edl, append: true, clampDuration: fileDuration });
    }, i18n.t('Failed to import project file'));
  }, [checkFileOpened, detectedFps, fileDuration, loadCutSegments, withErrorHandling]);

  useEffect(() => {
    const openFiles = async (filePaths: string[]) => userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p)));

    async function actionWithCatch(fn: () => Promise<void>) {
      try {
        await fn();
      } catch (err) {
        handleError({ err });
      }
    }

    const allActions = [
      // actions with arguments:
      [
        'openFiles',
        async (...argsRaw: unknown[]) => {
          await openFiles(...openFilesActionArgsSchema.parse(argsRaw));
        },
      ] as const,
      [
        'goToTimecodeDirect',
        async (...argsRaw: unknown[]) => {
          await goToTimecodeDirect(...goToTimecodeDirectArgsSchema.parse(argsRaw));
        },
      ] as const,
      ...Object.entries({
        // todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424
        importEdlFile,
        exportEdlFile: tryExportEdlFile,
        promptDownloadMediaUrl: promptDownloadMediaUrlWrapper,
      }).map(([key, fn]) => [
        key,
        async (...args: unknown[]) => {
          await (fn as (...args2: unknown[]) => Promise<void>)(...args);
        },
      ] as const),
      // all main actions (no arguments):
      ...Object.entries(mainActions).map(([key, fn]) => [
        key,
        async () => {
          fn();
        },
      ] as const),
    ];

    const allActionsMap = Object.fromEntries(allActions);

    const actionsWithCatch = allActions.map(([key, fn]) => [
      key,
      (...args: Parameters<typeof fn>) => actionWithCatch(() => fn(...args)),
    ] as const);

    async function tryApiAction(event: IpcRendererEvent, { id, action, args }: ApiActionRequest) {
      console.log('API action:', action, args);
      try {
        const fn = allActionsMap[action];
        if (!fn) throw new Error(`Action not found: ${action}`);
        // todo validate arguments
        await (args != null ? fn(...args) : fn());
      } catch (err) {
        console.error(err);
      } finally {
        // todo correlation ids
        event.sender.send('apiActionResponse', { id });
      }
    }

    const ipcActions = actionsWithCatch.map(([key, fn]) => [
      key,
      (_event: IpcRendererEvent, ...args: Parameters<typeof fn>) => actionWithCatch(() => fn(...args)),
    ] as const);

    ipcActions.forEach(([key, action]) => ipcRenderer.on(key, action));
    ipcRenderer.on('apiAction', tryApiAction);

    return () => {
      ipcActions.forEach(([key, action]) => ipcRenderer.off(key, action));
      ipcRenderer.off('apiAction', tryApiAction);
    };
  }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, goToTimecodeDirect, handleError, importEdlFile, loadCutSegments, mainActions, promptDownloadMediaUrlWrapper, selectedSegments, toggleKeyboardShortcuts, tryExportEdlFile, userOpenFiles]);

  const handleBatchFilesDrop = useCallback<DragEventHandler<HTMLDivElement>>(async (ev) => {
    ev.preventDefault();
    if (!ev.dataTransfer) return;
    await withErrorHandling(async () => {
      const filePaths = [...ev.dataTransfer.files].map((f) => webUtils.getPathForFile(f));
      await mainApi.focusWindow();
      batchLoadPaths(filePaths, true);
    });
  }, [batchLoadPaths, withErrorHandling]);

  const handleStreamSourceFileDrop = useCallback<DragEventHandler<HTMLDivElement>>(async (ev) => {
    ev.preventDefault();
    if (!ev.dataTransfer) return;
    await withErrorHandling(async () => {
      const filePaths = [...ev.dataTransfer.files].map((f) => webUtils.getPathForFile(f));
      if (filePaths.length !== 1) return;
      await mainApi.focusWindow();
      addStreamSourceFile(filePaths[0]!);
    });
  }, [addStreamSourceFile, withErrorHandling]);

  useEffect(() => {
    async function onDrop(ev: DragEvent) {
      ev.preventDefault();
      if (!ev.dataTransfer) return;
      const filePaths = [...ev.dataTransfer.files].map((f) => webUtils.getPathForFile(f));
      await mainApi.focusWindow();
      userOpenFiles(filePaths);
    }
    const element = videoContainerRef.current;
    element?.addEventListener('drop', onDrop);
    return () => element?.removeEventListener('drop', onDrop);
  }, [userOpenFiles, videoContainerRef]);

  useEffect(() => {
    function onDrop(ev: DragEvent) {
      // default drop handler to prevent new electron window from popping up https://github.com/electron/electron/issues/39839
      ev.preventDefault();
    }
    document.body.addEventListener('drop', onDrop);
    return () => document.body.removeEventListener('drop', onDrop);
  }, []);

  const renderOutFmt = useCallback((style: CSSProperties) => (
    <OutputFormatSelect style={style} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />
  ), [detectedFileFormat, fileFormat, onOutputFormatUserChange]);

  const onTunerRequested = useCallback((type: TunerType) => {
    setSettingsVisible(false);
    setTunerVisible(type);
    if (type === 'waveformHeight') {
      setWaveformMode('waveform');
    }
  }, [setWaveformMode]);

  useEffect(() => {
    if (!isStoreBuild && !hasDisabledNetworking()) loadMifiLink().then(setMifiLink);
  }, []);

  useEffect(() => {
    (async () => {
      setFfmpegInfo(await runStartupCheck({ onError: ({ title, message }) => setGenericError({ title, err: message }) }));
    })();
  }, [customFfPath, setGenericError]);

  const appContext = useMemo<AppContextType>(() => ({
    working,
    setWorking,
    handleError,
    showGenericDialog,
    confirmDialog,
    keyboardLayoutMap,
    updateKeyboardLayout,
  }), [confirmDialog, handleError, keyboardLayoutMap, setWorking, showGenericDialog, updateKeyboardLayout, working]);


  const showLeftBar = batchFiles.length > 0;

  function renderSubtitles() {
    if (!activeSubtitle) return null;
    return <track default kind="subtitles" label={activeSubtitle.lang} srcLang="en" src={activeSubtitle.url} />;
  }

  // throw new Error('Test error boundary');

  const baseColorStyle = useMemo(() => ({ color: 'var(--gray-12)', background: 'var(--gray-1)', colorScheme: darkMode ? 'only dark' : 'only light' }), [darkMode]);

  const rootClass = useMemo(() => [
    ...(darkMode ? ['dark-theme'] : []),
    ...(prefersReducedMotion ? ['no-animations'] : []),
  ].join(' '), [darkMode, prefersReducedMotion]);

  const rootStyle = useMemo<CSSProperties>(() => ({ ...baseColorStyle, display: 'flex', flexDirection: 'column', height: '100vh', transition: darkModeTransition }), [baseColorStyle]);

  return (
    <Theme appearance={darkMode ? 'dark' : 'light'} accentColor="cyan">
      <MotionConfig reducedMotion={reducedMotion}>
        <AppContext.Provider value={appContext}>
          <SegColorsContext.Provider value={segColorsContext}>
            <UserSettingsContext.Provider value={userSettingsContext}>
              <div className={rootClass} style={rootStyle} id="app-root">
                <TopMenu
                  filePath={filePath}
                  fileFormat={fileFormat}
                  changeEnabledStreamsFilter={changeEnabledStreamsFilter}
                  applyEnabledStreamsFilter={applyEnabledStreamsFilter}
                  enabledStreamsFilter={enabledStreamsFilter}
                  isCustomFormatSelected={isCustomFormatSelected}
                  renderOutFmt={renderOutFmt}
                  toggleSettings={toggleSettings}
                  numStreamsToCopy={numStreamsToCopy}
                  numStreamsTotal={numStreamsTotal}
                  setStreamsSelectorShown={setStreamsSelectorShown}
                  selectedSegments={segmentsOrInverse.selected}
                  toggleDarkMode={toggleDarkMode}
                />

                <div style={{ flexGrow: 1, display: 'flex', overflowY: 'hidden' }}>
                  <AnimatePresence>
                    {showLeftBar && (
                      <BatchFilesList
                        selectedBatchFiles={selectedBatchFiles}
                        filePath={filePath}
                        width={leftBarWidth}
                        batchFiles={batchFiles}
                        setBatchFiles={setBatchFiles}
                        onBatchFileSelect={onBatchFileSelect}
                        batchListRemoveFile={batchListRemoveFile}
                        closeBatch={closeBatch}
                        onMergeFilesClick={concatBatch}
                        onBatchConvertToSupportedFormatClick={convertFormatBatch}
                        onDrop={handleBatchFilesDrop}
                      />
                    )}
                  </AnimatePresence>

                  {/* Middle part (also shown in fullscreen): */}
                  <div style={{ position: 'relative', flexGrow: 1, overflow: 'hidden' }} ref={videoContainerRef}>
                    {!isFileOpened && <NoFileLoaded mifiLink={mifiLink} currentCutSeg={currentCutSeg} onClick={openFilesDialog} darkMode={darkMode} keyBindingByAction={keyBindingByAction} />}

                    <div className="no-user-select" style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, visibility: !isFileOpened || !hasVideo || bigWaveformEnabled ? 'hidden' : undefined }} onWheel={onTimelineWheel}>
                      {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
                      <video
                        className={styles['video']}
                        tabIndex={-1}
                        muted={playbackVolume === 0 || compatPlayerEnabled}
                        ref={videoRef}
                        style={videoStyle}
                        onPlay={onStartPlaying}
                        onPause={onStopPlaying}
                        onAbort={onVideoAbort}
                        onDurationChange={onDurationChange}
                        onTimeUpdate={onTimeUpdate}
                        onError={onVideoError}
                        onClick={onVideoClick}
                        onDoubleClick={toggleFullscreenVideo}
                        onFocusCapture={onVideoFocus}
                        onSeeked={onSeeked}
                      >
                        {renderSubtitles()}
                      </video>

                      {filePath != null && compatPlayerEnabled && <MediaSourcePlayer rotate={effectiveRotation} filePath={filePath} videoStream={activeVideoStream} audioStreams={activeAudioStreams} masterVideoRef={videoRef} mediaSourceQuality={mediaSourceQuality} ffmpegHwaccel={ffmpegHwaccel} />}
                    </div>

                    {bigWaveformEnabled && <BigWaveform waveforms={waveforms} relevantTime={relevantTime} playing={playing} fileDurationNonZero={fileDurationNonZero} zoom={zoomUnrounded} seekRel={seekRel} darkMode={darkMode} />}

                    {compatPlayerEnabled && (
                      <div style={{ position: 'absolute', top: 0, right: 0, left: 0, marginTop: '1em', marginLeft: '1em', color: 'var(--gray-12)', opacity: 0.7, display: 'flex', alignItems: 'center', pointerEvents: 'none' }}>
                        {isRotationSet ? (
                          <>
                            <MdRotate90DegreesCcw size={26} style={{ marginRight: 5 }} />
                            {t('Rotation preview')}
                          </>
                        ) : (
                          <>
                            {t('FFmpeg-assisted playback')}
                          </>
                        )}

                        <div style={{ cursor: 'pointer', pointerEvents: 'initial', color: 'var(--gray-12)', opacity: 0.7, padding: '.2em', marginLeft: '.5em' }} role="button" onClick={() => incrementMediaSourceQuality()} title={t('Select playback quality')}>{mediaSourceQualities[mediaSourceQuality]}</div>

                        {!compatPlayerRequired && <FaRegTimesCircle role="button" style={{ cursor: 'pointer', pointerEvents: 'initial', verticalAlign: 'middle', padding: '.2em' }} onClick={handleHideCompatPlayerClick} />}
                      </div>
                    )}

                    {isFileOpened && (
                      <div className="no-user-select" style={{ position: 'absolute', right: 0, bottom: 0, marginBottom: 10, display: 'flex', alignItems: 'flex-end' }}>
                        <VolumeControl playbackVolume={playbackVolume} setPlaybackVolume={setPlaybackVolume} onToggleMutedClick={toggleMuted} />

                        {shouldShowPlaybackStreamSelector && (
                          <PlaybackStreamSelector subtitleStreams={subtitleStreams} videoStreams={videoStreams} audioStreams={audioStreams} activeSubtitleStreamIndex={activeSubtitleStreamIndex} activeVideoStreamIndex={activeVideoStreamIndex} activeAudioStreamIndexes={activeAudioStreamIndexes} onActiveSubtitleChange={onActiveSubtitleChange} onActiveVideoStreamChange={onActiveVideoStreamChange} onActiveAudioStreamsChange={onActiveAudioStreamsChange} />
                        )}

                        {!showRightBar && (
                          <IoMdMenu
                            title={t('Show sidebar')}
                            size={30}
                            role="button"
                            style={{ marginRight: 10, color: 'var(--gray-12)', opacity: 0.7 }}
                            onClick={toggleSegmentsList}
                          />
                        )}
                      </div>
                    )}
                  </div>

                  <AnimatePresence>
                    {showRightBar && isFileOpened && filePath != null && (
                      <SegmentList
                        width={rightBarWidth}
                        currentSegIndex={currentSegIndexSafe}
                        cutSegments={cutSegments}
                        inverseCutSegments={inverseCutSegments}
                        getFrameCount={getFrameCount}
                        formatTimecode={formatTimecode}
                        onSegClick={setCurrentSegIndex}
                        updateSegOrder={updateSegOrder}
                        updateSegOrders={updateSegOrders}
                        onLabelSegment={labelSegment}
                        currentCutSeg={currentCutSeg}
                        firstSegmentAtCursor={firstSegmentAtCursor}
                        addSegment={addSegment}
                        onDuplicateSegmentClick={duplicateSegment}
                        removeSegment={removeSegment}
                        onRemoveSelected={removeSelectedSegments}
                        toggleSegmentsList={toggleSegmentsList}
                        splitCurrentSegment={splitCurrentSegment}
                        selectedSegments={segmentsOrInverse.selected}
                        onSelectSingleSegment={selectOnlySegment}
                        onToggleSegmentSelected={toggleSegmentSelected}
                        onDeselectAllSegments={deselectAllSegments}
                        onSelectAllSegments={selectAllSegments}
                        onInvertSelectedSegments={invertSelectedSegments}
                        onExtractSegmentsFramesAsImages={extractSegmentsFramesAsImages}
                        onExtractSelectedSegmentsFramesAsImages={extractSelectedSegmentsFramesAsImages}
                        jumpSegStart={jumpSegStart}
                        jumpSegEnd={jumpSegEnd}
                        onSelectSegmentsByLabel={selectSegmentsByLabel}
                        onSelectSegmentsByExpr={selectSegmentsByExpr}
                        onSelectAllMarkers={selectAllMarkers}
                        onMutateSegmentsByExpr={mutateSegmentsByExpr}
                        onLabelSelectedSegments={labelSelectedSegments}
                        updateSegAtIndex={updateSegAtIndex}
                        editingSegmentTags={editingSegmentTags}
                        editingSegmentTagsSegmentIndex={editingSegmentTagsSegmentIndex}
                        setEditingSegmentTags={setEditingSegmentTags}
                        setEditingSegmentTagsSegmentIndex={setEditingSegmentTagsSegmentIndex}
                        onEditSegmentTags={onEditSegmentTags}
                        getSegEstimatedSize={getSegEstimatedSize}
                      />
                    )}
                  </AnimatePresence>
                </div>

                <div style={bottomStyle}>
                  <Timeline
                    shouldShowKeyframes={shouldShowKeyframes}
                    waveforms={waveforms}
                    overviewWaveform={overviewWaveform}
                    shouldShowWaveform={shouldShowWaveform}
                    waveformEnabled={waveformEnabled}
                    waveformHeight={waveformHeight}
                    showThumbnails={showThumbnails}
                    neighbouringKeyFrames={neighbouringKeyFrames}
                    thumbnails={thumbnailsSorted}
                    playerTime={playerTime}
                    commandedTime={commandedTime}
                    relevantTime={relevantTime}
                    commandedTimeRef={commandedTimeRef}
                    startTimeOffset={startTimeOffset}
                    zoom={zoom}
                    seekAbs={seekAbs}
                    fileDurationNonZero={fileDurationNonZero}
                    cutSegments={cutSegments}
                    setCurrentSegIndex={setCurrentSegIndex}
                    currentSegIndexSafe={currentSegIndexSafe}
                    currentCutSeg={currentCutSeg}
                    inverseCutSegments={inverseCutSegments}
                    formatTimecode={formatTimecode}
                    zoomWindowStartTime={zoomWindowStartTime}
                    zoomWindowEndTime={zoomWindowEndTime}
                    onZoomWindowStartTimeChange={setZoomWindowStartTime}
                    onGenerateOverviewWaveformClick={generateOverviewWaveform}
                    playing={playing}
                    isFileOpened={isFileOpened}
                    onWheel={onTimelineWheel}
                    goToTimecode={goToTimecode}
                    darkMode={darkMode}
                    setCutTime={setCutTime}
                    setHoveringTime={setHoveringTime}
                  />

                  <BottomBar
                    zoom={zoom}
                    setZoom={zoomAbs}
                    timelineToggleComfortZoom={timelineToggleComfortZoom}
                    hasVideo={hasVideo}
                    isRotationSet={isRotationSet}
                    rotation={rotation}
                    areWeCutting={areWeCutting}
                    increaseRotation={increaseRotation}
                    cleanupFilesDialog={cleanupFilesDialog}
                    captureSnapshot={captureSnapshot}
                    onExportPress={onExportPress}
                    segmentsToExport={segmentsToExport}
                    seekAbs={seekAbs}
                    currentSegIndexSafe={currentSegIndexSafe}
                    cutSegments={cutSegments}
                    currentCutSeg={currentCutSeg}
                    selectedSegments={selectedSegments}
                    setCutStart={setCutStart}
                    setCutEnd={setCutEnd}
                    setCurrentSegIndex={setCurrentSegIndex}
                    jumpCutEnd={jumpCutEnd}
                    jumpCutStart={jumpCutStart}
                    jumpTimelineStart={jumpTimelineStart}
                    jumpTimelineEnd={jumpTimelineEnd}
                    startTimeOffset={startTimeOffset}
                    setCutTime={setCutTime}
                    playing={playing}
                    shortStep={shortStep}
                    seekClosestKeyframe={seekClosestKeyframe}
                    togglePlay={togglePlay}
                    showThumbnails={showThumbnails}
                    toggleShowThumbnails={toggleShowThumbnails}
                    toggleWaveformMode={toggleWaveformMode}
                    waveformMode={waveformMode}
                    hasAudio={hasAudio}
                    keyframesEnabled={keyframesEnabled}
                    toggleShowKeyframes={toggleShowKeyframes}
                    detectedFps={detectedFps}
                    toggleLoopSelectedSegments={toggleLoopSelectedSegments}
                    isFileOpened={isFileOpened}
                    darkMode={darkMode}
                    outputPlaybackRate={outputPlaybackRate}
                    setOutputPlaybackRate={setOutputPlaybackRate}
                    formatTimecode={formatTimecode}
                    parseTimecode={parseTimecode}
                    playbackRate={playbackRate}
                    currentFrame={currentFrame}
                    playbackMode={playbackMode}
                    displayTime={displayTime}
                    fileDurationNonZero={fileDurationNonZero}
                    getFrameCount={getFrameCount}
                  />
                </div>

                {tunerVisible != null && <ValueTuners type={tunerVisible} onFinished={() => setTunerVisible(undefined)} />}

                {/* Dialogs */}

                <ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} cutFileTemplate={cutFileTemplateOrDefault} cutMergedFileTemplate={cutMergedFileTemplateOrDefault} generateCutFileNames={generateCutFileNames} generateCutMergedFileNames={generateCutMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} neighbouringKeyFrames={neighbouringKeyFrames} findNearestKeyFrameTime={findNearestKeyFrameTime} />

                <Dialog.Root open={streamsSelectorShown} onOpenChange={setStreamsSelectorShown}>
                  <Dialog.Portal>
                    <Dialog.Overlay />
                    <Dialog.Content style={{ width: '80em' }}>
                      <Dialog.Title>{t('Tracks')}</Dialog.Title>
                      <Dialog.Description>{t('Click to select which tracks to keep when exporting:')}</Dialog.Description>

                      {mainStreams && filePath != null && (
                        <StreamsSelector
                          mainFilePath={filePath}
                          mainFileFormat={mainFileFormat}
                          mainFileChapters={mainFileChapters}
                          allFilesMeta={allFilesMeta}
                          externalFilesMeta={externalFilesMeta}
                          setExternalFilesMeta={setExternalFilesMeta}
                          showAddStreamSourceDialog={showIncludeExternalStreamsDialog}
                          mainFileStreams={mainStreams}
                          isCopyingStreamId={isCopyingStreamId}
                          toggleCopyStreamId={toggleCopyStreamId}
                          setCopyStreamIdsForPath={setCopyStreamIdsForPath}
                          onExtractAllStreamsPress={extractAllStreams}
                          onExtractStreamPress={extractSingleStream}
                          shortestFlag={shortestFlag}
                          setShortestFlag={setShortestFlag}
                          nonCopiedExtraStreams={nonCopiedExtraStreams}
                          paramsByFile={paramsByFile}
                          setParamsByFile={setParamsByFile}
                          updateStreamParams={updateStreamParams}
                          formatTimecode={formatTimecode}
                          loadSubtitleTrackToSegments={loadSubtitleTrackToSegments}
                          toggleCopyStreamIds={toggleCopyStreamIds}
                          changeEnabledStreamsFilter={changeEnabledStreamsFilter}
                          toggleCopyAllStreamsForPath={toggleCopyAllStreamsForPath}
                          onStreamSourceFileDrop={handleStreamSourceFileDrop}
                        />
                      )}

                      <Dialog.CloseButton />
                    </Dialog.Content>
                  </Dialog.Portal>
                </Dialog.Root>

                <LastCommands visible={lastCommandsVisible} onTogglePress={toggleLastCommands} ffmpegCommandLog={ffmpegCommandLog} setFfmpegCommandLog={setFfmpegCommandLog} />

                <Dialog.Root open={settingsVisible} onOpenChange={toggleSettings}>
                  <Dialog.Portal>
                    <Dialog.Overlay />
                    <Dialog.Content style={{ width: '60em' }}>
                      <Dialog.Title>{t('Settings')}</Dialog.Title>
                      <Dialog.Description>{t('Hover mouse over buttons in the main interface to see which function they have')}</Dialog.Description>

                      <Settings
                        onTunerRequested={onTunerRequested}
                        onKeyboardShortcutsDialogRequested={toggleKeyboardShortcuts}
                        askForCleanupChoices={askForCleanupChoices}
                        toggleStoreProjectInWorkingDir={toggleStoreProjectInWorkingDir}
                        showAdvancedSettings={showAdvancedSettings}
                        setShowAdvancedSettings={setShowAdvancedSettings}
                      />
                      <Dialog.CloseButton />
                    </Dialog.Content>
                  </Dialog.Portal>
                </Dialog.Root>

                <ConcatDialog isShown={batchFiles.length > 0 && concatDialogOpen} onHide={() => setConcatDialogOpen(false)} paths={batchFilePaths} mergedFileTemplate={mergedFileTemplateOrDefault} generateMergedFileNames={generateMergedFileNames} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} fileFormat={fileFormat} setFileFormat={setFileFormat} detectedFileFormat={detectedFileFormat} setDetectedFileFormat={setDetectedFileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />

                <KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} getKeyboardAction={getKeyboardAction} />

                {/* This should probably be last, so that it's always on top */}
                <AnimatePresence>
                  {working && <Working text={working.text} progress={progress} onAbortClick={abortWorking} />}
                </AnimatePresence>

                <GenericDialog dialog={genericDialog} onOpenChange={(open) => !open && closeGenericDialog()} />

                <WhatsNew />

                <ErrorDialog error={genericError} onOpenChange={(open) => !open && setGenericError(undefined)} />
              </div>

              <SwalContainer darkMode={darkMode} style={baseColorStyle} />
            </UserSettingsContext.Provider>
          </SegColorsContext.Provider>
        </AppContext.Provider>
      </MotionConfig>
    </Theme>
  );
}

export default memo(App);
