Skip to content

Commit

Permalink
feat(v2): start conversion & manage conversion progress (#108)
Browse files Browse the repository at this point in the history
* feat(v2): start conversion & manage conversion progress

* file card icon

* electron window api rename

* disable start conversion button when no file

* add tests

* fix lint

* fix lint

* renaming electron-api.ts to dialog.ts

* temp lint fix
  • Loading branch information
murgatt authored Dec 3, 2023
1 parent 98636fa commit 744c275
Show file tree
Hide file tree
Showing 33 changed files with 393 additions and 42 deletions.
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
dist-electron

# TODO temp fix for eslint/ts error in electron/file.types.ts
electron/file.types.ts
38 changes: 38 additions & 0 deletions electron/ConversionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ipcMain } from 'electron';
import type { VideoFile } from './file.types';
import type { BrowserWindow } from 'electron';

export class ConversionManager {
mainWindow: BrowserWindow;
isConversionInterrupted: boolean;

constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
this.isConversionInterrupted = false;

ipcMain.handle('start-conversion', (_event, { files }: { files: VideoFile[] }) => {
this.isConversionInterrupted = false;
this.handleFileConversionStart(files[0].path);
});

ipcMain.handle('stop-conversion', () => {
this.isConversionInterrupted = true;
});
}

handleFileConversionStart(filePath: string) {
this.mainWindow.webContents.send('file-conversion-start', { filePath });
}

handleFileConversionProgress() {
this.mainWindow.webContents.send('file-conversion-progress');
}

handleFileConversionEnd() {
this.mainWindow.webContents.send('file-conversion-end');
}

handleFileConversionError() {
this.mainWindow.webContents.send('file-conversion-error');
}
}
15 changes: 15 additions & 0 deletions electron/conversion-events.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { IpcRendererEvent } from 'electron';

export type FileConversionStartCallback = (event: IpcRendererEvent, { filePath }: { filePath: string }) => void;

export type FileConversionProgressCallback = (
event: IpcRendererEvent,
{ filePath, progress }: { filePath: string; progress: number },
) => void;

export type FileConversionEndCallback = (event: IpcRendererEvent, { filePath }: { filePath: string }) => void;

export type FileConversionErrorCallback = (
event: IpcRendererEvent,
{ filePath, error }: { error: string; filePath: string },
) => void;
File renamed without changes.
22 changes: 19 additions & 3 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
/// <reference types="vite-plugin-electron/electron-env" />
import type {
FileConversionEndCallback,
FileConversionErrorCallback,
FileConversionProgressCallback,
FileConversionStartCallback,
} from './conversion-events.types';
import type { VideoFile } from './file.types';

declare namespace NodeJS {
interface ProcessEnv {
DIST: string;
VITE_PUBLIC: string;
}
}

export interface IElectronAPI {
export interface IDialog {
openDirectory: () => Promise<string>;
}

export interface IConversion {
onFileConversionEnd: (callback: FileConversionEndCallback) => void;
onFileConversionError: (callback: FileConversionErrorCallback) => void;
onFileConversionProgress: (callback: FileConversionProgressCallback) => void;
onFileConversionStart: (callback: FileConversionStartCallback) => void;
startConversion: ({ files }: { files: VideoFile[] }) => void;
}

declare global {
interface Window {
electronAPI: IElectronAPI;
conversion: IConversion;
dialog: IDialog;
}
}
8 changes: 8 additions & 0 deletions electron/file.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type VideoFile = {
name: string;
path: string;
progress: number;
size: number;
status: 'imported' | 'converting' | 'conversionSuccess' | 'conversionError';
type: string;
};
19 changes: 14 additions & 5 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import path from 'node:path';
import { app, BrowserWindow, ipcMain } from 'electron';
import { handleOpenDirectory } from './electron-api';
import { ConversionManager } from './ConversionManager';
import { handleOpenDirectory } from './dialog';

process.env.DIST = path.join(__dirname, '../dist');
process.env.VITE_PUBLIC = app.isPackaged ? process.env.DIST : path.join(process.env.DIST, '../public');

// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];

let conversionManager: ConversionManager;

function createWindow() {
const win = new BrowserWindow({
const mainWindow = new BrowserWindow({
icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});

if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(path.join(process.env.DIST, 'index.html'));
}

if (conversionManager) {
conversionManager.mainWindow = mainWindow;
} else {
win.loadFile(path.join(process.env.DIST, 'index.html'));
conversionManager = new ConversionManager(mainWindow);
}

win.maximize();
mainWindow.maximize();
}

app.on('window-all-closed', () => {
Expand Down
18 changes: 17 additions & 1 deletion electron/preload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import { contextBridge, ipcRenderer } from 'electron';
import type {
FileConversionEndCallback,
FileConversionErrorCallback,
FileConversionProgressCallback,
FileConversionStartCallback,
} from './conversion-events.types';
import type { VideoFile } from './file.types';

contextBridge.exposeInMainWorld('electronAPI', {
contextBridge.exposeInMainWorld('dialog', {
openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
});

contextBridge.exposeInMainWorld('conversion', {
onFileConversionEnd: (callback: FileConversionEndCallback) => ipcRenderer.on('file-conversion-end', callback),
onFileConversionError: (callback: FileConversionErrorCallback) => ipcRenderer.on('file-conversion-error', callback),
onFileConversionProgress: (callback: FileConversionProgressCallback) =>
ipcRenderer.on('file-conversion-progress', callback),
onFileConversionStart: (callback: FileConversionStartCallback) => ipcRenderer.on('file-conversion-start', callback),
startConversion: ({ files }: { files: VideoFile[] }) => ipcRenderer.invoke('start-conversion', { files }),
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@hookform/resolvers": "3.3.2",
"@radix-ui/react-label": "2.0.2",
"@radix-ui/react-navigation-menu": "1.1.4",
"@radix-ui/react-progress": "1.0.3",
"@radix-ui/react-select": "2.0.0",
"@radix-ui/react-separator": "1.0.3",
"@radix-ui/react-slot": "1.0.2",
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Outlet } from 'react-router-dom';
import { AppMenu } from './components/AppMenu';
import { useConversionEvents } from './hooks/useConversionEvents';
import { useTheme } from './hooks/useTheme';

export const App = () => {
useTheme();
useConversionEvents();

return (
<div className="flex h-screen">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import { FileVideoIcon, TrashIcon } from 'lucide-react';
import { TrashIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useStore } from 'src/store';
import { fileStatusSchema } from 'src/types/file.types';
import { formatFileSize } from 'src/utils';
import { Button } from './ui/Button';
import { Card, CardDescription, CardHeader } from './ui/Card';
import { Tooltip } from './ui/Tooltip';
import { Button } from '../ui/Button';
import { Card, CardDescription, CardHeader } from '../ui/Card';
import { Progress } from '../ui/Progress';
import { Tooltip } from '../ui/Tooltip';
import { FileCardIcon } from './FileCardIcon';
import type { VideoFile } from 'src/types/file.types';

type FileCardProps = {
file: File;
file: VideoFile;
};

export const FileCard = ({ file }: FileCardProps) => {
const { t } = useTranslation();
const { name, path, size } = file;
const { name, path, progress, size, status } = file;
const formattedFileSize = formatFileSize(size);
const removeFile = useStore(state => state.removeFile);
const isConverting = status === fileStatusSchema.enum.converting;

return (
<Card>
<div className="flex items-center px-6">
<FileVideoIcon className="shrink-0" size="24" />
<FileCardIcon status={status} />
<CardHeader className="grow">
<h3 className="title-sm">{name}</h3>
<CardDescription>{formattedFileSize}</CardDescription>
Expand All @@ -36,6 +41,7 @@ export const FileCard = ({ file }: FileCardProps) => {
</Button>
</Tooltip>
</div>
<div className="h-1">{isConverting && <Progress className="h-1 rounded-t-none" value={progress} />}</div>
</Card>
);
};
20 changes: 20 additions & 0 deletions src/components/FileCard/FileCardIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AlertOctagonIcon, CheckCircleIcon, FileVideoIcon, Loader2Icon } from 'lucide-react';
import { fileStatusSchema } from 'src/types/file.types';
import type { FileStatus } from 'src/types/file.types';

type FileCardIconProps = {
status: FileStatus;
};

export const FileCardIcon = ({ status }: FileCardIconProps) => {
switch (status) {
case fileStatusSchema.enum.imported:
return <FileVideoIcon className="shrink-0" data-testid="FileCardIcon_fileIcon" size="24" />;
case fileStatusSchema.enum.converting:
return <Loader2Icon className="shrink-0 animate-spin" data-testid="FileCardIcon_loaderIcon" size="24" />;
case fileStatusSchema.enum.conversionSuccess:
return <CheckCircleIcon className="shrink-0 text-success" data-testid="FileCardIcon_checkIcon" size="24" />;
case fileStatusSchema.enum.conversionError:
return <AlertOctagonIcon className="shrink-0 text-destructive" data-testid="FileCardIcon_alertIcon" size="24" />;
}
};
33 changes: 33 additions & 0 deletions src/components/FileCard/__tests__/FileCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { fileStatusSchema } from 'src/types/file.types';
import { describe, expect, it } from 'vitest';
import { FileCard } from '../FileCard';
import type { VideoFile } from 'src/types/file.types';

describe('FileCard', () => {
it('should not display a progress bar if file is not converting', () => {
const file = {
name: 'matrix.mkv',
path: '/path/matrix.mkv',
progress: 0,
size: 1024,
status: fileStatusSchema.enum.imported,
} as VideoFile;
render(<FileCard file={file} />);

expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});

it('should display a progress bar if file is converting', () => {
const file = {
name: 'matrix.mkv',
path: '/path/matrix.mkv',
progress: 0,
size: 1024,
status: fileStatusSchema.enum.converting,
} as VideoFile;
render(<FileCard file={file} />);

expect(screen.getByRole('progressbar')).toBeVisible();
});
});
30 changes: 30 additions & 0 deletions src/components/FileCard/__tests__/FileCardIcon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { fileStatusSchema } from 'src/types/file.types';
import { describe, expect, it } from 'vitest';
import { FileCardIcon } from '../FileCardIcon';

describe('FileCardIcon', () => {
it('should display a video file icon when file status is imported', () => {
render(<FileCardIcon status={fileStatusSchema.enum.imported} />);

expect(screen.getByTestId('FileCardIcon_fileIcon')).toBeVisible();
});

it('should display a loader icon when file status is converting', () => {
render(<FileCardIcon status={fileStatusSchema.enum.converting} />);

expect(screen.getByTestId('FileCardIcon_loaderIcon')).toBeVisible();
});

it('should display a check icon when file status is success', () => {
render(<FileCardIcon status={fileStatusSchema.enum.conversionSuccess} />);

expect(screen.getByTestId('FileCardIcon_checkIcon')).toBeVisible();
});

it('should display an alert icon when file status is error', () => {
render(<FileCardIcon status={fileStatusSchema.enum.conversionError} />);

expect(screen.getByTestId('FileCardIcon_alertIcon')).toBeVisible();
});
});
1 change: 1 addition & 0 deletions src/components/FileCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FileCard } from './FileCard';
2 changes: 1 addition & 1 deletion src/components/FileImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const FileImport = ({ children }: FileImportProps) => {
});

return (
<div {...getRootProps()} className="relative h-full w-full">
<div {...getRootProps()} className="relative h-full w-full focus:outline-none">
<input {...getInputProps()} id="fileInput" value="" />
{displayFileList ? (
children
Expand Down
2 changes: 1 addition & 1 deletion src/components/Footer/DestinationInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const DestinationInput = () => {
}, [destinationPath, files, t]);

const handleOpenDirectory = async () => {
const newDestinationPath = await window.electronAPI.openDirectory();
const newDestinationPath = await window.dialog.openDirectory();
setDestinationPath(newDestinationPath);
};

Expand Down
Loading

0 comments on commit 744c275

Please sign in to comment.