diff --git a/src/renderer/errors.ts b/src/renderer/errors.ts new file mode 100644 index 00000000..8f1e5112 --- /dev/null +++ b/src/renderer/errors.ts @@ -0,0 +1,13 @@ +export class DirectoryAccessDeclinedError extends Error { + constructor() { + super(); + this.name = 'DirectoryAccessDeclinedError'; + } +} + +export class UnsupportedFileError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedFileError'; + } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e5890115..1e34912d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -24,7 +24,7 @@ import useKeyboard from './hooks/useKeyboard'; import useFileFormatState from './hooks/useFileFormatState'; import useFrameCapture from './hooks/useFrameCapture'; import useSegments from './hooks/useSegments'; -import useDirectoryAccess, { DirectoryAccessDeclinedError } from './hooks/useDirectoryAccess'; +import useDirectoryAccess from './hooks/useDirectoryAccess'; import { UserSettingsContext, SegColorsContext, UserSettingsContextType } from './contexts'; @@ -72,6 +72,7 @@ import { isMuxNotSupported, getDownloadMediaOutPath, isAbortedError, + withErrorHandling, } from './util'; import { toast, errorToast, showPlaybackFailedMessage } from './swal'; import { adjustRate } from './util/rate-calculator'; @@ -98,6 +99,7 @@ import useSubtitles from './hooks/useSubtitles'; import useStreamsMeta from './hooks/useStreamsMeta'; import { bottomStyle, videoStyle } from './styles'; import styles from './App.module.css'; +import { DirectoryAccessDeclinedError } from '../errors'; const electron = window.require('electron'); const { exists } = window.require('fs-extra'); @@ -492,13 +494,14 @@ function App() { } const subtitleStream = index != null && subtitleStreams.find((s) => s.index === index); if (!subtitleStream || workingRef.current) return; + + setWorking({ text: i18n.t('Loading subtitle') }); try { - setWorking({ text: i18n.t('Loading subtitle') }); - invariant(filePath != null); - await loadSubtitle({ filePath, index, subtitleStream }); - setActiveSubtitleStreamIndex(index); - } catch (err) { - handleError(`Failed to extract subtitles for stream ${index}`, err instanceof Error && err.message); + 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); } @@ -646,33 +649,31 @@ function App() { if (!speed) return; if (workingRef.current) return; + setWorking({ text: i18n.t('Batch converting to supported format') }); + setCutProgress(0); try { - setWorking({ text: i18n.t('Batch converting to supported format') }); - setCutProgress(0); + await withErrorHandling(async () => { + // eslint-disable-next-line no-restricted-syntax + for (const path of filePaths) { + try { + // eslint-disable-next-line no-await-in-loop + const newCustomOutDir = await ensureWritableOutDir({ inputPath: path, outDir: customOutDir }); - // eslint-disable-next-line no-restricted-syntax - for (const path of filePaths) { - try { - // eslint-disable-next-line no-await-in-loop - const newCustomOutDir = await ensureWritableOutDir({ inputPath: path, outDir: customOutDir }); + // eslint-disable-next-line no-await-in-loop + await html5ify({ customOutDir: newCustomOutDir, filePath: path, speed, hasAudio: true, hasVideo: true, onProgress: setTotalProgress }); + } catch (err2) { + if (err2 instanceof DirectoryAccessDeclinedError) return; - // eslint-disable-next-line no-await-in-loop - await html5ify({ customOutDir: newCustomOutDir, filePath: path, speed, hasAudio: true, hasVideo: true, onProgress: setTotalProgress }); - } catch (err2) { - if (err2 instanceof DirectoryAccessDeclinedError) return; + console.error('Failed to html5ify', path, err2); + failedFiles.push(path); + } - console.error('Failed to html5ify', path, err2); - failedFiles.push(path); + i += 1; + setTotalProgress(); } - i += 1; - setTotalProgress(); - } - - if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null as unknown as undefined, showConfirmButton: true }); - } catch (err) { - errorToast(i18n.t('Failed to batch convert to supported format')); - console.error('Failed to html5ify', err); + if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null as unknown as undefined, showConfirmButton: true }); + }, i18n.t('Failed to batch convert to supported format')); } finally { setWorking(undefined); setCutProgress(undefined); @@ -906,7 +907,7 @@ function App() { resetState(); } - try { + await withErrorHandling(async () => { const abortController = new AbortController(); setWorking({ text: i18n.t('Cleaning up'), abortController }); console.log('Cleaning up files', cleanupChoices2); @@ -917,10 +918,7 @@ function App() { 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 instanceof Error ? err.message : String(err) })); - console.error(err); - } + }, (err) => i18n.t('Unable to delete file: {{message}}', { message: err instanceof Error ? err.message : String(err) })); }, [batchListRemoveFile, filePath, previewFilePath, projectFileSavePath, resetState, setWorking]); const askForCleanupChoices = useCallback(async () => { @@ -1148,7 +1146,7 @@ function App() { const captureSnapshot = useCallback(async () => { if (!filePath) return; - try { + await withErrorHandling(async () => { const currentTime = getRelevantTime(); const video = videoRef.current; if (video == null) throw new Error(); @@ -1158,10 +1156,7 @@ function App() { : await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, quality: captureFrameQuality }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: outPath, text: `${i18n.t('Screenshot captured to:')} ${outPath}` }); - } catch (err) { - console.error(err); - errorToast(i18n.t('Failed to capture frame')); - } + }, i18n.t('Failed to capture frame')); }, [filePath, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, hideAllNotifications]); const extractSegmentFramesAsImages = useCallback(async (segIds: string[]) => { @@ -1199,7 +1194,6 @@ function App() { } } catch (err) { showOsNotification(i18n.t('Failed to extract frames')); - handleError(err); } finally { setWorking(undefined); @@ -1577,10 +1571,9 @@ function App() { if (workingRef.current) return; try { setWorking({ text: i18n.t('Converting to supported format') }); - await html5ifyAndLoad(customOutDir, filePath, selectedOption, hasVideo, hasAudio); - } catch (err) { - errorToast(i18n.t('Failed to convert file. Try a different conversion')); - console.error('Failed to html5ify file', err); + await withErrorHandling(async () => { + await html5ifyAndLoad(customOutDir, filePath, selectedOption, hasVideo, hasAudio); + }, i18n.t('Failed to convert file. Try a different conversion')); } finally { setWorking(undefined); } @@ -1605,16 +1598,15 @@ function App() { const tryFixInvalidDuration = useCallback(async () => { if (!checkFileOpened() || workingRef.current) return; try { - setWorking({ text: i18n.t('Fixing file duration') }); - setCutProgress(0); - invariant(fileFormat != null); - const path = await fixInvalidDuration({ fileFormat, customOutDir, onProgress: setCutProgress }); - showNotification({ icon: 'info', text: i18n.t('Duration has been fixed') }); + await withErrorHandling(async () => { + setWorking({ text: i18n.t('Fixing file duration') }); + setCutProgress(0); + invariant(fileFormat != null); + const path = await fixInvalidDuration({ fileFormat, customOutDir, onProgress: setCutProgress }); + showNotification({ icon: 'info', text: i18n.t('Duration has been fixed') }); - await loadMedia({ filePath: path }); - } catch (err) { - errorToast(i18n.t('Failed to fix file duration')); - console.error('Failed to fix file duration', err); + await loadMedia({ filePath: path }); + }, i18n.t('Failed to fix file duration')); } finally { setWorking(undefined); setCutProgress(undefined); @@ -1652,15 +1644,12 @@ function App() { const captureSnapshotAsCoverArt = useCallback(async () => { if (!filePath) return; - try { + await withErrorHandling(async () => { const currentTime = getRelevantTime(); const path = await captureFrameFromFfmpeg({ customOutDir, filePath, fromTime: currentTime, captureFormat, quality: captureFrameQuality }); if (!(await addFileAsCoverArt(path))) return; showNotification({ text: i18n.t('Current frame has been set as cover art') }); - } catch (err) { - console.error(err); - errorToast(i18n.t('Failed to capture frame')); - } + }, i18n.t('Failed to capture frame')); }, [addFileAsCoverArt, captureFormat, captureFrameFromFfmpeg, captureFrameQuality, customOutDir, filePath, getRelevantTime, showNotification]); const batchLoadPaths = useCallback((newPaths: string[], append?: boolean) => { @@ -1681,7 +1670,7 @@ function App() { }, []); const userOpenFiles = useCallback(async (filePathsIn?: string[]) => { - try { + await withErrorHandling(async () => { let filePaths = filePathsIn; if (!filePaths || filePaths.length === 0) return; @@ -1804,10 +1793,7 @@ function App() { } finally { setWorking(undefined); } - } catch (err) { - console.error('userOpenFiles', err); - handleError(i18n.t('Failed to open file'), err); - } + }, i18n.t('Failed to open file')); }, [workingRef, alwaysConcatMultipleFiles, batchLoadPaths, setWorking, isFileOpened, batchFiles.length, userOpenSingleFile, checkFileOpened, loadEdlFile, enableAskForFileOpenAction, addStreamSourceFile, filePath]); const openFilesDialog = useCallback(async () => { @@ -1840,14 +1826,12 @@ function App() { }, [isFileOpened, selectedSegments]); const showIncludeExternalStreamsDialog = useCallback(async () => { - try { + 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); - } catch (err) { - handleError(err); - } + }, i18n.t('Failed to include track')); }, [addStreamSourceFile, t]); const toggleFullscreenVideo = useCallback(async () => { @@ -1881,17 +1865,16 @@ function App() { const promptDownloadMediaUrlWrapper = useCallback(async () => { try { setWorking({ text: t('Downloading URL') }); - 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 }); - } catch (err) { - if (err instanceof DirectoryAccessDeclinedError) return; - handleError(err); + 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(); } @@ -1902,7 +1885,6 @@ function App() { const mainActions = useMemo(() => { async function exportYouTube() { if (!checkFileOpened()) return; - await openYouTubeChaptersDialog(formatYouTube(apparentCutSegments)); } @@ -2186,29 +2168,24 @@ function App() { const tryExportEdlFile = useCallback(async (type: EdlExportType) => { if (!checkFileOpened()) return; - try { + await withErrorHandling(async () => { await exportEdlFile({ type, cutSegments: selectedSegments, customOutDir, filePath, getFrameCount }); - } catch (err) { - errorToast(i18n.t('Failed to export project')); - console.error('Failed to export project', type, err); - } + }, i18n.t('Failed to export project')); }, [checkFileOpened, customOutDir, filePath, getFrameCount, selectedSegments]); const importEdlFile = useCallback(async (type: EdlImportType) => { if (!checkFileOpened()) return; - try { + await withErrorHandling(async () => { const edl = await askForEdlImport({ type, fps: detectedFps }); if (edl.length > 0) loadCutSegments(edl, true); - } catch (err) { - handleError(err); - } + }, i18n.t('Failed to import project file')); }, [checkFileOpened, detectedFps, loadCutSegments]); useEffect(() => { const openFiles = (filePaths: string[]) => { userOpenFiles(filePaths.map((p) => resolvePathIfNeeded(p))); }; - async function actionWithCatch(fn: () => void) { + async function actionWithCatch(fn: () => Promise) { try { await fn(); } catch (err) { diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index a2c3a82d..754d5364 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -544,8 +544,8 @@ export async function showConcatFailedDialog({ fileFormat }: { fileFormat: strin return value; } -export function openYouTubeChaptersDialog(text: string) { - ReactSwal.fire({ +export async function openYouTubeChaptersDialog(text: string) { + await ReactSwal.fire({ showCloseButton: true, title: i18n.t('YouTube Chapters'), html: ( diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index bfd34493..786a9ab4 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -11,6 +11,7 @@ import { isDurationValid } from './segments'; import { FFprobeChapter, FFprobeFormat, FFprobeProbeResult, FFprobeStream } from '../../../ffprobe'; import { parseSrt, parseSrtToSegments } from './edlFormats'; import { CopyfileStreams, LiteFFprobeStream } from './types'; +import { UnsupportedFileError } from '../errors'; const { pathExists } = window.require('fs-extra'); @@ -368,7 +369,7 @@ export async function readFileMeta(filePath: string) { return { format, streams, chapters }; } catch (err) { if (isExecaError(err)) { - throw Object.assign(new Error(`Unsupported file: ${err.message}`), { code: 'LLC_FFPROBE_UNSUPPORTED_FILE' }); + throw new UnsupportedFileError(err.message); } throw err; } diff --git a/src/renderer/src/hooks/useDirectoryAccess.ts b/src/renderer/src/hooks/useDirectoryAccess.ts index 27c1284f..a6efecbe 100644 --- a/src/renderer/src/hooks/useDirectoryAccess.ts +++ b/src/renderer/src/hooks/useDirectoryAccess.ts @@ -5,14 +5,9 @@ import invariant from 'tiny-invariant'; import { getOutDir, getFileDir, checkDirWriteAccess, dirExists, isMasBuild } from '../util'; import { askForOutDir, askForInputDir } from '../dialogs'; import { errorToast } from '../swal'; +import { DirectoryAccessDeclinedError } from '../../errors'; // import isDev from '../isDev'; -export class DirectoryAccessDeclinedError extends Error { - constructor() { - super(); - this.name = 'DirectoryAccessDeclinedError'; - } -} // MacOS App Store sandbox doesn't allow reading/writing anywhere, // except those exact file paths that have been explicitly drag-dropped into LosslessCut or opened using the opener dialog diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index d5c6d6b4..3da4da2c 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -10,6 +10,8 @@ import isDev from './isDev'; import Swal, { errorToast, toast } from './swal'; import { ffmpegExtractWindow } from './util/constants'; import { appName } from '../../main/common'; +import { DirectoryAccessDeclinedError, UnsupportedFileError } from '../errors'; +import { Html5ifyMode } from '../../../types'; const { dirname, parse: parsePath, join, extname, isAbsolute, resolve, basename } = window.require('path'); const fsExtra = window.require('fs-extra'); @@ -159,29 +161,6 @@ export async function transferTimestamps({ inPath, outPath, cutFrom = 0, cutTo = } } -export function handleError(arg1: unknown, arg2?: unknown) { - console.error('handleError', arg1, arg2); - - let err: Error | undefined; - let str: string | undefined; - - if (typeof arg1 === 'string') str = arg1; - else if (typeof arg2 === 'string') str = arg2; - - if (arg1 instanceof Error) err = arg1; - else if (arg2 instanceof Error) err = arg2; - - if (err != null && 'code' in err && err.code === 'LLC_FFPROBE_UNSUPPORTED_FILE') { - errorToast(i18n.t('Unsupported file')); - } else { - Swal.fire({ - icon: 'error', - title: str || i18n.t('An error has occurred.'), - text: err?.message ? err?.message.slice(0, 300) : undefined, - }); - } -} - export function filenamify(name: string) { return name.replaceAll(/[^\w.-]/g, '_'); } @@ -193,7 +172,7 @@ export function withBlur(cb) { }; } -export function dragPreventer(ev) { +export function dragPreventer(ev: DragEvent) { ev.preventDefault(); } @@ -237,7 +216,7 @@ export const resolvePathIfNeeded = (inPath: string) => (isAbsolute(inPath) ? inP export const html5ifiedPrefix = 'html5ified-'; export const html5dummySuffix = 'dummy'; -export async function findExistingHtml5FriendlyFile(fp, cod) { +export async function findExistingHtml5FriendlyFile(fp: string, cod: string | undefined) { // The order is the priority we will search: const suffixes = ['slowest', 'slow-audio', 'slow', 'fast-audio-remux', 'fast-audio', 'fast', html5dummySuffix]; const prefix = getSuffixedFileName(fp, html5ifiedPrefix); @@ -270,7 +249,7 @@ export async function findExistingHtml5FriendlyFile(fp, cod) { }; } -export function getHtml5ifiedPath(cod: string | undefined, fp, type) { +export function getHtml5ifiedPath(cod: string | undefined, fp: string, type: Html5ifyMode) { // See also inside ffmpegHtml5ify const ext = (isMac && ['slowest', 'slow', 'slow-audio'].includes(type)) ? 'mp4' : 'mkv'; return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` }); @@ -335,6 +314,55 @@ export const isMuxNotSupported = (err: InvariantExecaError) => ( && /Could not write header .*incorrect codec parameters .*Invalid argument/.test(getStdioString(err.stderr) ?? '') ); +export function handleError(arg1: unknown, arg2?: unknown) { + console.error('handleError', arg1, arg2); + + let err: Error | undefined; + let str: string | undefined; + + if (typeof arg1 === 'string') str = arg1; + else if (typeof arg2 === 'string') str = arg2; + + if (arg1 instanceof Error) err = arg1; + else if (arg2 instanceof Error) err = arg2; + + if (err instanceof UnsupportedFileError) { + errorToast(i18n.t('Unsupported file')); + } else { + Swal.fire({ + icon: 'error', + title: str || i18n.t('An error has occurred.'), + text: err?.message ? err?.message.slice(0, 300) : undefined, + }); + } +} + +/** + * Run an operation with error handling + */ +export async function withErrorHandling(operation: () => Promise, errorMsgOrFn?: string | ((err: unknown) => string)) { + try { + await operation(); + } catch (err) { + if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return; + + if (err instanceof UnsupportedFileError) { + errorToast(i18n.t('Unsupported file')); + return; + } + + let errorMsg: string | undefined; + if (typeof errorMsgOrFn === 'string') errorMsg = errorMsgOrFn; + if (typeof errorMsgOrFn === 'function') errorMsg = errorMsgOrFn(err); + if (errorMsg != null) { + console.error(errorMsg, err); + errorToast(errorMsg); + } else { + handleError(err); + } + } +} + export async function checkAppPath() { try { const forceCheck = false; @@ -364,10 +392,10 @@ export async function checkAppPath() { } // https://stackoverflow.com/a/2450976/6519037 -export function shuffleArray(arrayIn) { +export function shuffleArray(arrayIn: T[]) { const array = [...arrayIn]; let currentIndex = array.length; - let randomIndex; + let randomIndex: number; // While there remain elements to shuffle... while (currentIndex !== 0) { @@ -377,23 +405,24 @@ export function shuffleArray(arrayIn) { // And swap it with the current element. [array[currentIndex], array[randomIndex]] = [ - array[randomIndex], array[currentIndex]]; + array[randomIndex]!, array[currentIndex]!, + ] as const; } return array; } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping -export function escapeRegExp(string) { +export function escapeRegExp(str: string) { // eslint-disable-next-line unicorn/better-regex - return string.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } -export const readFileSize = async (path) => (await stat(path)).size; +export const readFileSize = async (path: string) => (await stat(path)).size; -export const readFileSizes = (paths) => pMap(paths, async (path) => readFileSize(path), { concurrency: 5 }); +export const readFileSizes = (paths: string[]) => pMap(paths, async (path) => readFileSize(path), { concurrency: 5 }); -export function checkFileSizes(inputSize, outputSize) { +export function checkFileSizes(inputSize: number, outputSize: number) { const diff = Math.abs(outputSize - inputSize); const relDiff = diff / inputSize; const maxDiffPercent = 5;