diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fb550aa8..857cfce0 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -39,6 +39,7 @@ module.exports = { '@typescript-eslint/no-unused-vars': 0, // todo 'import/extensions': 0, // doesn't work with TS https://github.com/import-js/eslint-plugin-import/issues/2111 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }], + 'react/require-default-props': 0, }, parserOptions: { ecmaVersion: 2022, diff --git a/index.html b/index.html index 1d1691cd..de6a3c3e 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,6 @@
- + diff --git a/package.json b/package.json index 621b7222..4d965ef8 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "sweetalert2": "^11.0.0", "sweetalert2-react-content": "^5.0.7", "typescript": "^5.3.3", + "typescript-plugin-css-modules": "^5.1.0", "use-debounce": "^5.1.0", "use-trace-update": "^1.3.0", "uuid": "^8.3.2", diff --git a/src/App.jsx b/src/App.tsx similarity index 92% rename from src/App.jsx rename to src/App.tsx index 2a62ba6f..995f429b 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useState, useCallback, useRef, useMemo } from 'react'; +import { memo, useEffect, useState, useCallback, useRef, useMemo, CSSProperties } from 'react'; import { FaAngleLeft, FaWindowClose } from 'react-icons/fa'; import { MdRotate90DegreesCcw } from 'react-icons/md'; import { AnimatePresence } from 'framer-motion'; @@ -85,6 +85,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; +import { EdlFileType, FfmpegCommandLog, Html5ifyMode, TunerType } from './types'; const electron = window.require('electron'); const { exists } = window.require('fs-extra'); @@ -96,8 +97,8 @@ const remote = window.require('@electron/remote'); const { focusWindow, hasDisabledNetworking, quitApp } = remote.require('./electron'); -const videoStyle = { width: '100%', height: '100%', objectFit: 'contain' }; -const bottomStyle = { background: controlsBackground, transition: darkModeTransition }; +const videoStyle: CSSProperties = { width: '100%', height: '100%', objectFit: 'contain' }; +const bottomStyle: CSSProperties = { background: controlsBackground, transition: darkModeTransition }; const hevcPlaybackSupportedPromise = doesPlayerSupportHevcPlayback(); hevcPlaybackSupportedPromise.catch((err) => console.error(err)); @@ -106,66 +107,66 @@ hevcPlaybackSupportedPromise.catch((err) => console.error(err)); function App() { // Per project state const [commandedTime, setCommandedTime] = useState(0); - const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); + const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); - const [previewFilePath, setPreviewFilePath] = useState(); - const [working, setWorkingState] = useState(); + const [previewFilePath, setPreviewFilePath] = useState(); + const [working, setWorkingState] = useState<{ text: string, abortController: AbortController }>(); const [usingDummyVideo, setUsingDummyVideo] = useState(false); const [playing, setPlaying] = useState(false); const [compatPlayerEventId, setCompatPlayerEventId] = useState(0); - const playbackModeRef = useRef(); - const [playerTime, setPlayerTime] = useState(); - const [duration, setDuration] = useState(); + const playbackModeRef = useRef<{ playbackMode: 'loop-selected-segments', segId: string }>(); + const [playerTime, setPlayerTime] = useState(); + const [duration, setDuration] = useState(); const [rotation, setRotation] = useState(360); - const [cutProgress, setCutProgress] = useState(); + const [cutProgress, setCutProgress] = useState(); const [startTimeOffset, setStartTimeOffset] = useState(0); const [filePath, setFilePath] = useState(''); const [externalFilesMeta, setExternalFilesMeta] = useState({}); const [customTagsByFile, setCustomTagsByFile] = useState({}); const [paramsByStreamId, setParamsByStreamId] = useState(new Map()); - const [detectedFps, setDetectedFps] = useState(); - const [mainFileMeta, setMainFileMeta] = useState({ streams: [], formatData: {} }); - const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({}); + const [detectedFps, setDetectedFps] = useState(); + const [mainFileMeta, setMainFileMeta] = useState<{ streams: any[], formatData: any, chapters?: any }>({ streams: [], formatData: {} }); + const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState>>({}); const [streamsSelectorShown, setStreamsSelectorShown] = useState(false); const [concatDialogVisible, setConcatDialogVisible] = useState(false); const [zoomUnrounded, setZoom] = useState(1); - const [thumbnails, setThumbnails] = useState([]); + const [thumbnails, setThumbnails] = useState<{ from: number, url: string }[]>([]); const [shortestFlag, setShortestFlag] = useState(false); const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0); - const [subtitlesByStreamId, setSubtitlesByStreamId] = useState({}); - const [activeVideoStreamIndex, setActiveVideoStreamIndex] = useState(); - const [activeAudioStreamIndex, setActiveAudioStreamIndex] = useState(); - const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState(); + const [subtitlesByStreamId, setSubtitlesByStreamId] = useState>({}); + const [activeVideoStreamIndex, setActiveVideoStreamIndex] = useState(); + const [activeAudioStreamIndex, setActiveAudioStreamIndex] = useState(); + const [activeSubtitleStreamIndex, setActiveSubtitleStreamIndex] = useState(); const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false); const [exportConfirmVisible, setExportConfirmVisible] = useState(false); const [cacheBuster, setCacheBuster] = useState(0); - const [mergedOutFileName, setMergedOutFileName] = useState(); + const [mergedOutFileName, setMergedOutFileName] = useState(); const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1); const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); // State per application launch const lastOpenedPathRef = useRef(); - const [waveformMode, setWaveformMode] = useState(); + const [waveformMode, setWaveformMode] = useState<'big-waveform' | 'waveform'>(); const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false); const [keyframesEnabled, setKeyframesEnabled] = useState(true); const [showRightBar, setShowRightBar] = useState(true); - const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); + const [rememberConvertToSupportedFormat, setRememberConvertToSupportedFormat] = useState(); const [lastCommandsVisible, setLastCommandsVisible] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); - const [tunerVisible, setTunerVisible] = useState(); + const [tunerVisible, setTunerVisible] = useState(); const [keyboardShortcutsVisible, setKeyboardShortcutsVisible] = useState(false); - const [mifiLink, setMifiLink] = useState(); + const [mifiLink, setMifiLink] = useState(); const [alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles] = useState(false); - const [editingSegmentTagsSegmentIndex, setEditingSegmentTagsSegmentIndex] = useState(); - const [editingSegmentTags, setEditingSegmentTags] = useState(); + const [editingSegmentTagsSegmentIndex, setEditingSegmentTagsSegmentIndex] = useState(); + const [editingSegmentTags, setEditingSegmentTags] = useState>(); const [mediaSourceQuality, setMediaSourceQuality] = useState(0); const incrementMediaSourceQuality = useCallback(() => setMediaSourceQuality((v) => (v + 1) % mediaSourceQualities.length), []); // Batch state / concat files - const [batchFiles, setBatchFiles] = useState([]); - const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); + const [batchFiles, setBatchFiles] = useState<{ path: string }[]>([]); + const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); // Store "working" in a ref so we can avoid race conditions const workingRef = useRef(!!working); @@ -205,8 +206,8 @@ function App() { electron.ipcRenderer.send('setLanguage', l); }, [language]); - const videoRef = useRef(); - const videoContainerRef = useRef(); + const videoRef = useRef(null); + const videoContainerRef = useRef(null); const setOutputPlaybackRate = useCallback((v) => { setOutputPlaybackRateState(v); @@ -244,8 +245,8 @@ function App() { }); }, [zoomedDuration]); - function appendFfmpegCommandLog(command) { - setFfmpegCommandLog(old => [...old, { command, time: new Date() }]); + function appendFfmpegCommandLog(command: string) { + setFfmpegCommandLog((old) => [...old, { command, time: new Date() }]); } const setCopyStreamIdsForPath = useCallback((path, cb) => { @@ -267,7 +268,7 @@ function App() { if (waveformMode === 'waveform') { setWaveformMode('big-waveform'); } else if (waveformMode === 'big-waveform') { - setWaveformMode(); + setWaveformMode(undefined); } else { if (!hideAllNotifications) toast.fire({ text: i18n.t('Mini-waveform has been enabled. Click again to enable full-screen waveform') }); setWaveformMode('waveform'); @@ -285,7 +286,7 @@ function App() { const seekAbs = useCallback((val) => { const video = videoRef.current; - if (val == null || Number.isNaN(val)) return; + if (video == null || val == null || Number.isNaN(val)) return; let outVal = val; if (outVal < 0) outVal = 0; if (outVal > video.duration) outVal = video.duration; @@ -295,7 +296,7 @@ function App() { setCompatPlayerEventId((id) => id + 1); // To make sure that we can seek even to the same commanded time that we are already add (e.g. loop current segment) }, []); - const userSeekAbs = useCallback((val) => { + const userSeekAbs = useCallback((val: number) => { playbackModeRef.current = undefined; // If the user seeks, we clear any custom playback mode return seekAbs(val); }, [seekAbs]); @@ -305,8 +306,8 @@ function App() { commandedTimeRef.current = commandedTime; }, [commandedTime]); - const seekRel = useCallback((val) => { - userSeekAbs(videoRef.current.currentTime + val); + const seekRel = useCallback((val: number) => { + userSeekAbs(videoRef.current!.currentTime + val); }, [userSeekAbs]); const seekRelPercent = useCallback((val) => { @@ -319,7 +320,7 @@ function App() { const fps = detectedFps || 30; // try to align with frame - const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current.currentTime); + const currentTimeNearestFrameNumber = getFrameCountRaw(fps, videoRef.current!.currentTime); const nextFrame = currentTimeNearestFrameNumber + direction; userSeekAbs(nextFrame / fps); }, [detectedFps, userSeekAbs]); @@ -344,7 +345,7 @@ function App() { const activeVideoStream = useMemo(() => (activeVideoStreamIndex != null ? videoStreams.find((stream) => stream.index === activeVideoStreamIndex) : undefined) ?? mainVideoStream, [activeVideoStreamIndex, mainVideoStream, videoStreams]); const activeAudioStream = useMemo(() => (activeAudioStreamIndex != null ? audioStreams.find((stream) => stream.index === activeAudioStreamIndex) : undefined) ?? mainAudioStream, [activeAudioStreamIndex, audioStreams, mainAudioStream]); - const activeSubtitle = useMemo(() => subtitlesByStreamId[activeSubtitleStreamIndex], [activeSubtitleStreamIndex, subtitlesByStreamId]); + const activeSubtitle = useMemo(() => activeSubtitleStreamIndex != null ? subtitlesByStreamId[activeSubtitleStreamIndex] : undefined, [activeSubtitleStreamIndex, subtitlesByStreamId]); // 360 means we don't modify rotation gtrgt const isRotationSet = rotation !== 360; @@ -377,7 +378,7 @@ function App() { // Relevant time is the player's playback position if we're currently playing - if not, it's the user's commanded time. const relevantTime = useMemo(() => (playing ? playerTime : commandedTime) || 0, [commandedTime, playerTime, playing]); // The reason why we also have a getter is because it can be used when we need to get the time, but don't want to re-render for every time update (which can be heavy!) - const getRelevantTime = useCallback(() => (playing ? videoRef.current.currentTime : commandedTimeRef.current) || 0, [playing]); + const getRelevantTime = useCallback(() => (playing ? videoRef.current!.currentTime : commandedTimeRef.current) || 0, [playing]); const maxLabelLength = safeOutputFileName ? 100 : 500; @@ -399,12 +400,12 @@ function App() { const jumpTimelineEnd = useCallback(() => userSeekAbs(durationSafe), [durationSafe, userSeekAbs]); - const getFrameCount = useCallback((sec) => getFrameCountRaw(detectedFps, sec), [detectedFps]); + const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]); const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => { if (timecodeFormat === 'frameCount') { const frameCount = getFrameCount(seconds); - return frameCount != null ? frameCount : ''; + return frameCount != null ? String(frameCount) : ''; } if (timecodeFormat === 'timecodeWithFramesFraction') { return formatDuration({ seconds, fps: detectedFps, shorten, fileNameFriendly }); @@ -436,7 +437,7 @@ function App() { // https://github.com/mifi/lossless-cut/issues/1674 if (cacheBuster !== 0) { const qs = new URLSearchParams(); - qs.set('t', cacheBuster); + qs.set('t', String(cacheBuster)); return `${uri}?${qs.toString()}`; } return uri; @@ -445,7 +446,7 @@ function App() { const projectSuffix = 'proj.llc'; const oldProjectSuffix = 'llc-edl.csv'; // New LLC format can be stored along with input file or in working dir (customOutDir) - const getEdlFilePath = useCallback((fp, cod) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: projectSuffix }), []); + const getEdlFilePath = useCallback((fp: string, cod?: string) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: projectSuffix }), []); // Old versions of LosslessCut used CSV files and stored them always in customOutDir: const getEdlFilePathOld = useCallback((fp, cod) => getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: oldProjectSuffix }), []); const getProjectFileSavePath = useCallback((storeProjectInWorkingDirIn) => getEdlFilePath(filePath, storeProjectInWorkingDirIn ? customOutDir : undefined), [getEdlFilePath, filePath, customOutDir]); @@ -458,7 +459,7 @@ function App() { const [debouncedSaveOperation] = useDebounce(currentSaveOperation, isDev ? 2000 : 500); - const lastSaveOperation = useRef(); + const lastSaveOperation = useRef(); useEffect(() => { async function save() { // NOTE: Could lose a save if user closes too fast, but not a big issue I think @@ -486,7 +487,7 @@ function App() { function onPlayingChange(val) { setPlaying(val); if (!val) { - setCommandedTime(videoRef.current.currentTime); + setCommandedTime(videoRef.current!.currentTime); } } @@ -513,7 +514,7 @@ function App() { setRotation((r) => (r + 90) % 450); setHideMediaSourcePlayer(false); // Matroska is known not to work, so we warn user. See https://github.com/mifi/lossless-cut/discussions/661 - const supportsRotation = !['matroska', 'webm'].includes(fileFormat); + const supportsRotation = !(fileFormat != null && ['matroska', 'webm'].includes(fileFormat)); if (!supportsRotation && !hideAllNotifications) toast.fire({ text: i18n.t('Lossless rotation might not work with this file format. You may try changing to MP4') }); }, [hideAllNotifications, fileFormat]); @@ -563,7 +564,7 @@ function App() { const clearOutDir = useCallback(async () => { try { await ensureWritableOutDir({ inputPath: filePath, outDir: undefined }); - setCustomOutDir(); + setCustomOutDir(undefined); } catch (err) { if (err instanceof DirectoryAccessDeclinedError) return; throw err; @@ -595,9 +596,9 @@ function App() { }, }), [preferStrongColors]); - const onActiveSubtitleChange = useCallback(async (index) => { + const onActiveSubtitleChange = useCallback(async (index?: number) => { if (index == null) { - setActiveSubtitleStreamIndex(); + setActiveSubtitleStreamIndex(undefined); return; } if (subtitlesByStreamId[index]) { // Already loaded @@ -612,18 +613,20 @@ function App() { setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } })); setActiveSubtitleStreamIndex(index); } catch (err) { - handleError(`Failed to extract subtitles for stream ${index}`, err.message); + handleError(`Failed to extract subtitles for stream ${index}`, err instanceof Error && err.message); } finally { - setWorking(); + setWorking(undefined); } }, [setWorking, subtitleStreams, subtitlesByStreamId, filePath]); - const onActiveVideoStreamChange = useCallback((index) => { + const onActiveVideoStreamChange = useCallback((index?: number) => { + if (!videoRef.current) throw new Error(); setHideMediaSourcePlayer(index == null || getVideoTrackForStreamIndex(videoRef.current, index) != null); enableVideoTrack(videoRef.current, index); setActiveVideoStreamIndex(index); }, []); - const onActiveAudioStreamChange = useCallback((index) => { + const onActiveAudioStreamChange = useCallback((index?: number) => { + if (!videoRef.current) throw new Error(); setHideMediaSourcePlayer(index == null || getAudioTrackForStreamIndex(videoRef.current, index) != null); enableAudioTrack(videoRef.current, index); setActiveAudioStreamIndex(index); @@ -670,8 +673,8 @@ function App() { const toggleStripAudio = useCallback(() => toggleStripStream((stream) => stream.codec_type === 'audio'), [toggleStripStream]); const toggleStripThumbnail = useCallback(() => toggleStripStream(isStreamThumbnail), [toggleStripStream]); - const thumnailsRef = useRef([]); - const thumnailsRenderingPromiseRef = useRef(); + const thumnailsRef = useRef<{ from: number, url: string }[]>([]); + const thumnailsRenderingPromiseRef = useRef>(); function addThumbnail(thumbnail) { // console.log('Rendered thumbnail', thumbnail.url); @@ -681,7 +684,7 @@ function App() { const hasAudio = !!activeAudioStream; const hasVideo = !!activeVideoStream; - const waveformEnabled = hasAudio && ['waveform', 'big-waveform'].includes(waveformMode); + const waveformEnabled = hasAudio && waveformMode != null && ['waveform', 'big-waveform'].includes(waveformMode); const bigWaveformEnabled = waveformEnabled && waveformMode === 'big-waveform'; const showThumbnails = thumbnailsEnabled && hasVideo; @@ -739,28 +742,28 @@ function App() { console.log('State reset'); const video = videoRef.current; setCommandedTime(0); - video.currentTime = 0; - video.playbackRate = 1; + video!.currentTime = 0; + video!.playbackRate = 1; // setWorking(); - setPreviewFilePath(); + setPreviewFilePath(undefined); setUsingDummyVideo(false); setPlaying(false); playbackModeRef.current = undefined; setCompatPlayerEventId(0); - setDuration(); + setDuration(undefined); cutSegmentsHistory.go(0); clearSegments(); - setFileFormat(); - setDetectedFileFormat(); + setFileFormat(undefined); + setDetectedFileFormat(undefined); setRotation(360); - setCutProgress(); + setCutProgress(undefined); setStartTimeOffset(0); setFilePath(''); // Setting video src="" prevents memory leak in chromium setExternalFilesMeta({}); setCustomTagsByFile({}); setParamsByStreamId(new Map()); - setDetectedFps(); + setDetectedFps(undefined); setMainFileMeta({ streams: [], formatData: [] }); setCopyStreamIdsByFile({}); setStreamsSelectorShown(false); @@ -770,9 +773,9 @@ function App() { setZoomWindowStartTime(0); setDeselectedSegmentIds({}); setSubtitlesByStreamId({}); - setActiveAudioStreamIndex(); - setActiveVideoStreamIndex(); - setActiveSubtitleStreamIndex(); + setActiveAudioStreamIndex(undefined); + setActiveVideoStreamIndex(undefined); + setActiveSubtitleStreamIndex(undefined); setHideMediaSourcePlayer(false); setExportConfirmVisible(false); resetMergedOutFileName(); @@ -809,7 +812,7 @@ function App() { setCutProgress(0); await html5ifyDummy({ filePath: fp, outPath: path, onProgress: setCutProgress }); } finally { - setCutProgress(); + setCutProgress(undefined); } return path; } @@ -818,7 +821,7 @@ function App() { const shouldIncludeVideo = !usesDummyVideo && hv; return await html5ify({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: shouldIncludeVideo, onProgress: setCutProgress }); } finally { - setCutProgress(); + setCutProgress(undefined); } } @@ -833,7 +836,7 @@ function App() { if (batchFiles.length < 1) return; const filePaths = batchFiles.map((f) => f.path); - const failedFiles = []; + const failedFiles: string[] = []; let i = 0; const setTotalProgress = (fileProgress = 0) => setCutProgress((i + fileProgress) / filePaths.length); @@ -864,13 +867,13 @@ function App() { setTotalProgress(); } - if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null, showConfirmButton: true }); + if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null as any as undefined, showConfirmButton: true }); } catch (err) { errorToast(i18n.t('Failed to batch convert to supported format')); console.error('Failed to html5ify', err); } finally { - setWorking(); - setCutProgress(); + setWorking(undefined); + setCutProgress(undefined); } }, [batchFiles, customOutDir, ensureWritableOutDir, html5ify, setWorking]); @@ -889,10 +892,10 @@ function App() { const pause = useCallback(() => { if (!filePath || !playing) return; - videoRef.current.pause(); + videoRef.current!.pause(); }, [filePath, playing]); - const play = useCallback((resetPlaybackRate) => { + const play = useCallback((resetPlaybackRate?: boolean) => { if (!filePath || playing) return; const video = videoRef.current; @@ -900,8 +903,8 @@ function App() { // This was added to re-sync time if file gets reloaded #1674 - but I had to remove this because it broke loop-selected-segments https://github.com/mifi/lossless-cut/discussions/1785#discussioncomment-7852134 // if (Math.abs(commandedTimeRef.current - video.currentTime) > 1) video.currentTime = commandedTimeRef.current; - if (resetPlaybackRate) video.playbackRate = outputPlaybackRate; - video.play().catch((err) => { + if (resetPlaybackRate) video!.playbackRate = outputPlaybackRate; + video?.play().catch((err) => { showPlaybackFailedMessage(); console.error(err); }); @@ -982,7 +985,7 @@ function App() { newBatch.splice(index, 1); const newItemAtIndex = newBatch[index]; if (newItemAtIndex != null) setSelectedBatchFiles([newItemAtIndex.path]); - else if (newBatch.length > 0) setSelectedBatchFiles([newBatch[0].path]); + else if (newBatch.length > 0) setSelectedBatchFiles([newBatch[0]!.path]); else setSelectedBatchFiles([]); return newBatch; }); @@ -995,7 +998,7 @@ function App() { preserveMetadataOnMerge, }), [ffmpegExperimental, movFastStart, preserveMetadataOnMerge, preserveMovData]); - const openSendReportDialogWithState = useCallback(async (err) => { + const openSendReportDialogWithState = useCallback(async (err?: unknown) => { const state = { ...commonSettings, @@ -1057,8 +1060,8 @@ function App() { const metadataFromPath = paths[0]; const { haveExcludedStreams } = await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setCutProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments, appendFfmpegCommandLog }); - const warnings = []; - const notices = []; + const warnings: string[] = []; + const notices: string[] = []; const outputSize = await readFileSize(outPath); // * 1.06; // testing:) const sizeCheckResult = checkFileSizes(inputSize, outputSize); @@ -1070,28 +1073,30 @@ function App() { } catch (err) { if (err instanceof DirectoryAccessDeclinedError) return; - if (err.killed === true) { - // assume execa killed (aborted by user) - return; - } - - console.error('stdout:', err.stdout); - console.error('stderr:', err.stderr); - - if (isExecaFailure(err)) { - if (isOutOfSpaceError(err)) { - showDiskFull(); + if (err instanceof Error) { + if ('killed' in err && err.killed === true) { + // assume execa killed (aborted by user) + return; + } + + if ('stdout' in err) console.error('stdout:', err.stdout); + if ('stderr' in err) console.error('stderr:', err.stderr); + + if (isExecaFailure(err)) { + if (isOutOfSpaceError(err)) { + showDiskFull(); + return; + } + const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters }; + handleConcatFailed(err, reportState); return; } - const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters }; - handleConcatFailed(err, reportState); - return; } handleError(err); } finally { - setWorking(); - setCutProgress(); + setWorking(undefined); + setCutProgress(undefined); } }, [setWorking, ensureWritableOutDir, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, handleConcatFailed]); @@ -1111,14 +1116,14 @@ function App() { setWorking({ text: i18n.t('Cleaning up'), abortController }); console.log('Cleaning up files', cleanupChoices2); - const pathsToDelete = []; + 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 }); } catch (err) { - errorToast(i18n.t('Unable to delete file: {{message}}', { message: err.message })); + errorToast(i18n.t('Unable to delete file: {{message}}', { message: err instanceof Error ? err.message : String(err) })); console.error(err); } }, [batchListRemoveFile, filePath, previewFilePath, projectFileSavePath, resetState, setWorking]); @@ -1148,12 +1153,12 @@ function App() { try { await cleanupFilesWithDialog(); } finally { - setWorking(); + setWorking(undefined); } }, [cleanupFilesWithDialog, isFileOpened, setWorking]); - const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template, forceSafeOutputFileName }) => ( - generateOutSegFileNamesRaw({ segments, template, forceSafeOutputFileName, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }) + const generateOutSegFileNames = useCallback(({ segments = segmentsToExport, template }) => ( + generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }) ), [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]); const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []); @@ -1267,7 +1272,7 @@ function App() { if (exportExtraStreams) { try { - setCutProgress(); // If extracting extra streams takes a long time, prevent loader from being stuck at 100% + setCutProgress(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({ filePath, customOutDir, streams: nonCopiedExtraStreams, enableOverwriteOutput }); notices.push(i18n.t('Unprocessable streams were exported as separate files.')); @@ -1286,27 +1291,29 @@ function App() { resetMergedOutFileName(); } catch (err) { - if (err.killed === true) { - // assume execa killed (aborted by user) - return; - } - - console.error('stdout:', err.stdout); - console.error('stderr:', err.stderr); - - if (isExecaFailure(err)) { - if (isOutOfSpaceError(err)) { - showDiskFull(); + if (err instanceof Error) { + if ('killed' in err && err.killed === true) { + // assume execa killed (aborted by user) + return; + } + + if ('stdout' in err) console.error('stdout:', err.stdout); + if ('stderr' in err) console.error('stderr:', err.stderr); + + if (isExecaFailure(err)) { + if (isOutOfSpaceError(err)) { + showDiskFull(); + return; + } + handleExportFailed(err); return; } - handleExportFailed(err); - return; } handleError(err); } finally { - setWorking(); - setCutProgress(); + setWorking(undefined); + setCutProgress(undefined); } }, [numStreamsToCopy, segmentsToExport, haveInvalidSegs, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, resetMergedOutFileName, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, filePath, handleExportFailed]); @@ -1370,15 +1377,15 @@ function App() { } catch (err) { handleError(err); } finally { - setWorking(); - setCutProgress(); + setWorking(undefined); + setCutProgress(undefined); } }, [apparentCutSegments, captureFormat, captureFrameFileNameFormat, captureFrameQuality, captureFramesRange, customOutDir, detectedFps, filePath, getFrameCount, hideAllNotifications, outputDir, setWorking]); const extractCurrentSegmentFramesAsImages = useCallback(() => extractSegmentFramesAsImages([currentCutSeg?.segId]), [currentCutSeg?.segId, extractSegmentFramesAsImages]); const extractSelectedSegmentsFramesAsImages = useCallback(() => extractSegmentFramesAsImages(selectedSegments.map((seg) => seg.segId)), [extractSegmentFramesAsImages, selectedSegments]); - const changePlaybackRate = useCallback((dir, rateMultiplier) => { + const changePlaybackRate = useCallback((dir: number, rateMultiplier?: number) => { if (compatPlayerEnabled) { toast.fire({ title: i18n.t('Unable to change playback rate right now'), timer: 1000 }); return; @@ -1386,11 +1393,11 @@ function App() { const video = videoRef.current; if (!playing) { - video.play(); + video!.play(); } else { - const newRate = adjustRate(video.playbackRate, dir, rateMultiplier); + const newRate = adjustRate(video!.playbackRate, dir, rateMultiplier); toast.fire({ title: `${i18n.t('Playback rate:')} ${Math.round(newRate * 100)}%`, timer: 1000 }); - video.playbackRate = newRate; + video!.playbackRate = newRate; } }, [playing, compatPlayerEnabled]); @@ -1398,10 +1405,11 @@ function App() { const segmentsAtCursorIndexes = findSegmentsAtCursor(apparentCutSegments, commandedTime); const firstSegmentAtCursorIndex = segmentsAtCursorIndexes[0]; + if (firstSegmentAtCursorIndex == null) return undefined; return cutSegments[firstSegmentAtCursorIndex]; }, [apparentCutSegments, commandedTime, cutSegments]); - const loadEdlFile = useCallback(async ({ path, type, append }) => { + const loadEdlFile = useCallback(async ({ path, type, append }: { path: string, type: EdlFileType, append?: boolean }) => { console.log('Loading EDL file', type, path, append); loadCutSegments(await readEdlFile({ type, path }), append); }, [loadCutSegments]); @@ -1443,7 +1451,7 @@ function App() { } catch (err) { if (err instanceof DirectoryAccessDeclinedError) throw err; console.error('EDL load failed, but continuing', err); - errorToast(`${i18n.t('Failed to load segments')} (${err.message})`); + errorToast(`${i18n.t('Failed to load segments')} (${err instanceof Error && err.message})`); } } @@ -1622,14 +1630,14 @@ function App() { } catch (err) { handleError(err); } finally { - setWorking(); + setWorking(undefined); } }, [userOpenSingleFile, setWorking, filePath]); const batchFileJump = useCallback((direction) => { if (batchFiles.length === 0) return; if (selectedBatchFiles.length === 0) { - setSelectedBatchFiles([batchFiles[0].path]); + setSelectedBatchFiles([batchFiles[0]!.path]); return; } const selectedFilePath = selectedBatchFiles[direction > 0 ? selectedBatchFiles.length - 1 : 0]; @@ -1645,7 +1653,7 @@ function App() { batchOpenSingleFile(selectedBatchFiles[0]); }, [batchOpenSingleFile, selectedBatchFiles]); - const onBatchFileSelect = useCallback((path) => { + const onBatchFileSelect = useCallback((path: string) => { if (selectedBatchFiles.includes(path)) batchOpenSingleFile(path); else setSelectedBatchFiles([path]); }, [batchOpenSingleFile, selectedBatchFiles]); @@ -1687,7 +1695,7 @@ function App() { errorToast(i18n.t('Failed to extract all streams')); console.error('Failed to extract all streams', err); } finally { - setWorking(); + setWorking(undefined); } }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainCopiedStreams, setWorking]); @@ -1697,7 +1705,7 @@ function App() { let selectedOption = rememberConvertToSupportedFormat; if (selectedOption == null || ignoreRememberedValue) { - let allowedOptions = []; + let allowedOptions: Html5ifyMode[] = []; if (hasAudio && hasVideo) allowedOptions = ['fastest', 'fast-audio-remux', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest']; else if (hasAudio) allowedOptions = ['fast-audio-remux', 'slow-audio', 'slowest']; else if (hasVideo) allowedOptions = ['fastest', 'fast', 'slow', 'slowest']; @@ -1720,7 +1728,7 @@ function App() { errorToast(i18n.t('Failed to convert file. Try a different conversion')); console.error('Failed to html5ify file', err); } finally { - setWorking(); + setWorking(undefined); } }, [customOutDir, filePath, html5ifyAndLoad, hasVideo, hasAudio, rememberConvertToSupportedFormat, setWorking]); @@ -1751,12 +1759,12 @@ function App() { errorToast(i18n.t('Failed to fix file duration')); console.error('Failed to fix file duration', err); } finally { - setWorking(); - setCutProgress(); + setWorking(undefined); + setCutProgress(undefined); } }, [checkFileOpened, customOutDir, duration, fileFormat, fixInvalidDuration, hideAllNotifications, loadMedia, setWorking]); - const addStreamSourceFile = useCallback(async (path) => { + const addStreamSourceFile = useCallback(async (path: string) => { if (allFilesMeta[path]) return undefined; // Already added? const fileMeta = await readFileMeta(path); // console.log('streams', fileMeta.streams); @@ -1794,15 +1802,19 @@ function App() { } }, [addFileAsCoverArt, captureFormat, captureFrameFromFfmpeg, captureFrameQuality, customOutDir, filePath, getRelevantTime, hideAllNotifications]); - const batchLoadPaths = useCallback((newPaths, append) => { + const batchLoadPaths = useCallback((newPaths: string[], append?: boolean) => { setBatchFiles((existingFiles) => { const mapPathsToFiles = (paths) => paths.map((path) => ({ path, name: basename(path) })); if (append) { const newUniquePaths = newPaths.filter((newPath) => !existingFiles.some(({ path: existingPath }) => newPath === existingPath)); - setSelectedBatchFiles([newUniquePaths[0]]); + const [firstNewUniquePath] = newUniquePaths; + if (firstNewUniquePath == null) throw new Error(); + setSelectedBatchFiles([firstNewUniquePath]); return [...existingFiles, ...mapPathsToFiles(newUniquePaths)]; } - setSelectedBatchFiles([newPaths[0]]); + const [firstNewPath] = newPaths; + if (firstNewPath == null) throw new Error(); + setSelectedBatchFiles([firstNewPath]); return mapPathsToFiles(newPaths); }); }, []); @@ -1874,7 +1886,7 @@ function App() { const isLlcProject = filePathLowerCase.endsWith('.llc'); // Need to ask the user what to do if more than one option - const inputOptions = { + const inputOptions: { open: string, project?: string, tracks?: string, subtitles?: string, addToBatch?: string, mergeWithCurrentFile?: string } = { open: isFileOpened ? i18n.t('Open the file instead of the current one') : i18n.t('Open the file'), }; @@ -1912,7 +1924,7 @@ function App() { return; } if (openFileResponse === 'mergeWithCurrentFile') { - const batchPaths = new Set(); + const batchPaths = new Set(); if (filePath) batchPaths.add(filePath); filePaths.forEach((path) => batchPaths.add(path)); batchLoadPaths([...batchPaths]); @@ -1927,13 +1939,13 @@ function App() { await userOpenSingleFile({ path: firstFilePath, isLlcProject }); } catch (err) { console.error('userOpenFiles', err); - if (err.code === 'LLC_FFPROBE_UNSUPPORTED_FILE') { + if (err instanceof Error && 'code' in err && err.code === 'LLC_FFPROBE_UNSUPPORTED_FILE') { errorToast(i18n.t('Unsupported file')); } else { handleError(i18n.t('Failed to open file'), err); } } finally { - setWorking(); + setWorking(undefined); } }, [alwaysConcatMultipleFiles, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile, filePath]); @@ -1979,13 +1991,14 @@ function App() { console.warn('No video tag to full screen'); return; } + if (videoContainerRef.current == null) throw new Error('videoContainerRef.current == null'); await screenfull.toggle(videoContainerRef.current, { navigationUI: 'hide' }); } catch (err) { console.error('Failed to toggle fullscreen', err); } }, []); - const onEditSegmentTags = useCallback((index) => { + const onEditSegmentTags = useCallback((index: number) => { setEditingSegmentTagsSegmentIndex(index); setEditingSegmentTags(getSegmentTags(apparentCutSegments[index])); }, [apparentCutSegments]); @@ -1994,7 +2007,7 @@ function App() { onEditSegmentTags(currentSegIndexSafe); }, [currentSegIndexSafe, onEditSegmentTags]); - const mainActions = useMemo(() => { + const mainActions: Record void> = useMemo(() => { async function exportYouTube() { if (!checkFileOpened()) return; @@ -2129,9 +2142,9 @@ function App() { }; }, [addSegment, alignSegmentTimesToKeyframes, apparentCutSegments, askStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, checkFileOpened, cleanupFilesDialog, clearSegments, closeBatch, closeFileWithConfirm, combineOverlappingSegments, combineSelectedSegments, concatBatch, convertFormatBatch, copySegmentsToClipboard, createFixedDurationSegments, createNumSegments, createRandomSegments, createSegmentsFromKeyframes, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, detectBlackScenes, detectSceneChanges, detectSilentScenes, duplicateCurrentSegment, editCurrentSegmentTags, extractAllStreams, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, fillSegmentsGaps, goToTimecode, handleShowStreamsSelectorClick, increaseRotation, invertAllSegments, invertSelectedSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, onExportPress, onLabelSegment, openFilesDialog, openSendReportDialogWithState, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shiftAllSegmentTimes, shortStep, showIncludeExternalStreamsDialog, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleFullscreenVideo, toggleKeyboardShortcuts, toggleKeyframeCut, toggleLastCommands, toggleLoopSelectedSegments, togglePlay, toggleSegmentsList, toggleSettings, toggleShowKeyframes, toggleShowThumbnails, toggleStreamsSelector, toggleStripAudio, toggleStripThumbnail, toggleWaveformMode, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]); - const getKeyboardAction = useCallback((action) => mainActions[action], [mainActions]); + const getKeyboardAction = useCallback((action: string) => mainActions[action], [mainActions]); - const onKeyPress = useCallback(({ action, keyup }) => { + const onKeyPress = useCallback(({ action, keyup }: { action: string, keyup: boolean }) => { function tryMainActions() { const fn = getKeyboardAction(action); if (!fn) return { match: false }; @@ -2204,14 +2217,14 @@ function App() { errorToast(i18n.t('Failed to extract track')); console.error('Failed to extract track', err); } finally { - setWorking(); + setWorking(undefined); } }, [customOutDir, enableOverwriteOutput, filePath, hideAllNotifications, mainStreams, setWorking]); const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]); const onVideoError = useCallback(async () => { - const { error } = videoRef.current; + const error = videoRef.current?.error; if (!error) return; if (!fileUri) return; // Probably MEDIA_ELEMENT_ERROR: Empty src attribute @@ -2242,7 +2255,7 @@ function App() { console.error(err); showPlaybackFailedMessage(); } finally { - setWorking(); + setWorking(undefined); } } catch (err) { handleError(err); @@ -2292,14 +2305,14 @@ function App() { } } - const actionsWithArgs = { - openFiles: (filePaths) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); }, + const actionsWithArgs: Record void> = { + openFiles: (filePaths: string[]) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); }, // todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424 importEdlFile, exportEdlFile: tryExportEdlFile, }; - async function actionWithCatch(fn) { + async function actionWithCatch(fn: () => void) { try { await fn(); } catch (err) { @@ -2307,17 +2320,17 @@ function App() { } } - const actionsWithCatch = [ + const actionsWithCatch: Readonly<[string, (event: unknown, ...a: any) => Promise]>[] = [ // actions with arguments: ...Object.entries(actionsWithArgs).map(([key, fn]) => [ key, - async (event, ...args) => actionWithCatch(() => fn(...args)), - ]), + async (_event: unknown, ...args: unknown[]) => actionWithCatch(() => fn(...args)), + ] as const), // all main actions (no arguments, except keyup which we don't support): ...Object.entries(mainActions).map(([key, fn]) => [ key, async () => actionWithCatch(() => fn({ keyup: false })), - ]), + ] as const), ]; actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.on(key, action)); @@ -2330,8 +2343,9 @@ function App() { }, [checkFileOpened, customOutDir, detectedFps, filePath, getFrameCount, getKeyboardAction, loadCutSegments, mainActions, selectedSegments, userOpenFiles]); useEffect(() => { - async function onDrop(ev) { + async function onDrop(ev: DragEvent) { ev.preventDefault(); + if (!ev.dataTransfer) return; const { files } = ev.dataTransfer; const filePaths = Array.from(files).map(f => f.path); @@ -2343,11 +2357,11 @@ function App() { return () => document.body.removeEventListener('drop', onDrop); }, [userOpenFiles]); - const renderOutFmt = useCallback((style) => ( + const renderOutFmt = useCallback((style: CSSProperties) => ( ), [detectedFileFormat, fileFormat, onOutputFormatUserChange]); - const onTunerRequested = useCallback((type) => { + const onTunerRequested = useCallback((type: TunerType) => { setSettingsVisible(false); setTunerVisible(type); }, []); @@ -2392,6 +2406,7 @@ function App() {
{showLeftBar && ( } - {tunerVisible && setTunerVisible()} />} + {tunerVisible && setTunerVisible(undefined)} />}
{showRightBar && isFileOpened && ( + {/* @ts-expect-error todo */} setStreamsSelectorShown(false)} maxWidth={1000}> {mainStreams && ( 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} /> + {/* @ts-expect-error todo */} setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} mainActions={mainActions} />
diff --git a/src/LastCommandsSheet.jsx b/src/LastCommandsSheet.tsx similarity index 85% rename from src/LastCommandsSheet.jsx rename to src/LastCommandsSheet.tsx index dac8df01..9e0141ea 100644 --- a/src/LastCommandsSheet.jsx +++ b/src/LastCommandsSheet.tsx @@ -1,10 +1,13 @@ -import React, { memo } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import CopyClipboardButton from './components/CopyClipboardButton'; import Sheet from './components/Sheet'; +import { FfmpegCommandLog } from './types'; -const LastCommandsSheet = memo(({ visible, onTogglePress, ffmpegCommandLog }) => { +const LastCommandsSheet = memo(({ visible, onTogglePress, ffmpegCommandLog }: { + visible: boolean, onTogglePress: () => void, ffmpegCommandLog: FfmpegCommandLog, +}) => { const { t } = useTranslation(); return ( diff --git a/src/NoFileLoaded.jsx b/src/NoFileLoaded.tsx similarity index 85% rename from src/NoFileLoaded.jsx rename to src/NoFileLoaded.tsx index 976fa164..e4d9144a 100644 --- a/src/NoFileLoaded.jsx +++ b/src/NoFileLoaded.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import { memo } from 'react'; import { useTranslation, Trans } from 'react-i18next'; @@ -8,7 +8,9 @@ import useUserSettings from './hooks/useUserSettings'; const electron = window.require('electron'); -const NoFileLoaded = memo(({ mifiLink, currentCutSeg, onClick, darkMode }) => { +const NoFileLoaded = memo(({ mifiLink, currentCutSeg, onClick, darkMode }: { + mifiLink: unknown, currentCutSeg, onClick: () => void, darkMode?: boolean, +}) => { const { t } = useTranslation(); const { simpleMode } = useUserSettings(); @@ -37,12 +39,11 @@ const NoFileLoaded = memo(({ mifiLink, currentCutSeg, onClick, darkMode }) => { )} - - {mifiLink && mifiLink.loadUrl && ( + {mifiLink && typeof mifiLink === 'object' && 'loadUrl' in mifiLink && typeof mifiLink.loadUrl === 'string' && mifiLink.loadUrl && (