mirror of
https://git.unlock-music.dev/um/um-react.git
synced 2024-11-24 00:42:16 +00:00
feat: friendly way to inform user there's an error (#12)
This commit is contained in:
parent
4aff3b7a07
commit
52d86e4601
@ -1 +1,17 @@
|
|||||||
export class UnsupportedSourceFile extends Error {}
|
export enum DecryptErrorType {
|
||||||
|
UNSUPPORTED_FILE = 'UNSUPPORTED_FILE',
|
||||||
|
UNKNOWN = 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DecryptError extends Error {
|
||||||
|
code = DecryptErrorType.UNKNOWN;
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
const { name, message, stack, code } = this;
|
||||||
|
return { name, message, stack, code };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsupportedSourceFile extends DecryptError {
|
||||||
|
code = DecryptErrorType.UNSUPPORTED_FILE;
|
||||||
|
}
|
||||||
|
@ -38,7 +38,7 @@ class DecryptCommandHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('could not decrypt file: no working decryptor found');
|
throw new UnsupportedSourceFile('could not decrypt file: no working decryptor found');
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptFile(crypto: CryptoBase) {
|
async decryptFile(crypto: CryptoBase) {
|
||||||
|
36
src/features/file-listing/FileError.tsx
Normal file
36
src/features/file-listing/FileError.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
||||||
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
|
||||||
|
export interface FileErrorProps {
|
||||||
|
error: null | string;
|
||||||
|
code: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMap = new Map<string | null | DecryptErrorType, string>([
|
||||||
|
[DecryptErrorType.UNSUPPORTED_FILE, '尚未支持的文件格式'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function FileError({ error, code }: FileErrorProps) {
|
||||||
|
const { isOpen, onToggle } = useDisclosure();
|
||||||
|
const errorSummary = errorMap.get(code) ?? '未知错误';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text>
|
||||||
|
<chakra.span>解密错误:{errorSummary}</chakra.span>
|
||||||
|
{error && (
|
||||||
|
<Button ml="2" onClick={onToggle} type="button">
|
||||||
|
详细
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{error && (
|
||||||
|
<Collapse in={isOpen} animateOpacity>
|
||||||
|
<Box as="pre" display="inline-block" mt="2" px="4" py="2" bg="red.300" rounded="md">
|
||||||
|
{error}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
@ -18,6 +18,7 @@ import { useAppDispatch } from '~/hooks';
|
|||||||
import { AnimationDefinition } from 'framer-motion';
|
import { AnimationDefinition } from 'framer-motion';
|
||||||
import { AlbumImage } from './AlbumImage';
|
import { AlbumImage } from './AlbumImage';
|
||||||
import { SongMetadata } from './SongMetadata';
|
import { SongMetadata } from './SongMetadata';
|
||||||
|
import { FileError } from './FileError';
|
||||||
|
|
||||||
interface FileRowProps {
|
interface FileRowProps {
|
||||||
id: string;
|
id: string;
|
||||||
@ -73,7 +74,10 @@ export function FileRow({ id, file }: FileRowProps) {
|
|||||||
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
|
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
|
||||||
</Box>
|
</Box>
|
||||||
</GridItem>
|
</GridItem>
|
||||||
<GridItem area="meta">{isDecrypted && metadata && <SongMetadata metadata={metadata} />}</GridItem>
|
<GridItem area="meta">
|
||||||
|
{isDecrypted && metadata && <SongMetadata metadata={metadata} />}
|
||||||
|
{file.state === ProcessState.ERROR && <FileError error={file.errorMessage} code={file.errorCode} />}
|
||||||
|
</GridItem>
|
||||||
<GridItem area="action" alignSelf="center">
|
<GridItem area="action" alignSelf="center">
|
||||||
<VStack>
|
<VStack>
|
||||||
{file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />}
|
{file.decrypted && <audio controls autoPlay={false} src={file.decrypted} ref={audioPlayerRef} />}
|
||||||
|
@ -5,8 +5,9 @@ export const untouchedFile: DecryptedAudioFile = {
|
|||||||
raw: 'blob://localhost/file-a',
|
raw: 'blob://localhost/file-a',
|
||||||
decrypted: '',
|
decrypted: '',
|
||||||
ext: '',
|
ext: '',
|
||||||
state: ProcessState.UNTOUCHED,
|
state: ProcessState.QUEUED,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
errorCode: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ export const completedFile: DecryptedAudioFile = {
|
|||||||
ext: 'flac',
|
ext: 'flac',
|
||||||
state: ProcessState.COMPLETE,
|
state: ProcessState.COMPLETE,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
errorCode: null,
|
||||||
metadata: {
|
metadata: {
|
||||||
name: 'Für Alice',
|
name: 'Für Alice',
|
||||||
artist: 'Jixun',
|
artist: 'Jixun',
|
||||||
@ -33,6 +35,7 @@ export const fileWithError: DecryptedAudioFile = {
|
|||||||
ext: 'flac',
|
ext: 'flac',
|
||||||
state: ProcessState.ERROR,
|
state: ProcessState.ERROR,
|
||||||
errorMessage: 'Could not decrypt blah blah',
|
errorMessage: 'Could not decrypt blah blah',
|
||||||
|
errorCode: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,9 +4,11 @@ import type { RootState } from '~/store';
|
|||||||
import { decryptionQueue } from '~/decrypt-worker/client';
|
import { decryptionQueue } from '~/decrypt-worker/client';
|
||||||
|
|
||||||
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
||||||
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
|
||||||
export enum ProcessState {
|
export enum ProcessState {
|
||||||
UNTOUCHED = 'UNTOUCHED',
|
QUEUED = 'QUEUED',
|
||||||
|
PROCESSING = 'PROCESSING',
|
||||||
COMPLETE = 'COMPLETE',
|
COMPLETE = 'COMPLETE',
|
||||||
ERROR = 'ERROR',
|
ERROR = 'ERROR',
|
||||||
}
|
}
|
||||||
@ -31,6 +33,7 @@ export interface DecryptedAudioFile {
|
|||||||
decrypted: string; // blob uri
|
decrypted: string; // blob uri
|
||||||
state: ProcessState;
|
state: ProcessState;
|
||||||
errorMessage: null | string;
|
errorMessage: null | string;
|
||||||
|
errorCode: null | DecryptErrorType | string;
|
||||||
metadata: null | AudioMetadata;
|
metadata: null | AudioMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +57,11 @@ export const processFile = createAsyncThunk<
|
|||||||
return thunkAPI.rejectWithValue({ message, stack });
|
return thunkAPI.rejectWithValue({ message, stack });
|
||||||
}
|
}
|
||||||
|
|
||||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw });
|
const onPreProcess = () => {
|
||||||
|
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return decryptionQueue.add({ id: fileId, blobURI: file.raw }, onPreProcess);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fileListingSlice = createSlice({
|
export const fileListingSlice = createSlice({
|
||||||
@ -67,8 +74,9 @@ export const fileListingSlice = createSlice({
|
|||||||
raw: payload.blobURI,
|
raw: payload.blobURI,
|
||||||
decrypted: '',
|
decrypted: '',
|
||||||
ext: '',
|
ext: '',
|
||||||
state: ProcessState.UNTOUCHED,
|
state: ProcessState.QUEUED,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
errorCode: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -78,6 +86,12 @@ export const fileListingSlice = createSlice({
|
|||||||
file.decrypted = payload.decryptedBlobURI;
|
file.decrypted = payload.decryptedBlobURI;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setFileAsProcessing: (state, { payload }: PayloadAction<{ id: string }>) => {
|
||||||
|
const file = state.files[payload.id];
|
||||||
|
if (file) {
|
||||||
|
file.state = ProcessState.PROCESSING;
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteFile: (state, { payload }: PayloadAction<{ id: string }>) => {
|
deleteFile: (state, { payload }: PayloadAction<{ id: string }>) => {
|
||||||
if (state.files[payload.id]) {
|
if (state.files[payload.id]) {
|
||||||
const file = state.files[payload.id];
|
const file = state.files[payload.id];
|
||||||
@ -108,16 +122,14 @@ export const fileListingSlice = createSlice({
|
|||||||
const file = state.files[fileId];
|
const file = state.files[fileId];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (action.payload) {
|
file.errorMessage = action.error.message ?? 'unknown error';
|
||||||
file.errorMessage = action.payload.message;
|
file.errorCode = action.error.code ?? null;
|
||||||
} else {
|
file.state = ProcessState.ERROR;
|
||||||
file.errorMessage = action.error.message || 'unknown error';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { addNewFile, setDecryptedContent, deleteFile } = fileListingSlice.actions;
|
export const { addNewFile, setFileAsProcessing, setDecryptedContent, deleteFile } = fileListingSlice.actions;
|
||||||
|
|
||||||
export const selectFileCount = (state: RootState) => state.fileListing.files.length;
|
export const selectFileCount = (state: RootState) => state.fileListing.files.length;
|
||||||
export const selectFiles = (state: RootState) => state.fileListing.files;
|
export const selectFiles = (state: RootState) => state.fileListing.files;
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { nextTickAsync } from './nextTick';
|
import { nextTickAsync } from './nextTick';
|
||||||
|
|
||||||
export abstract class ConcurrentQueue<T, R = unknown> {
|
export abstract class ConcurrentQueue<T, R = unknown> {
|
||||||
protected items: [T, (result: R) => void, (error: unknown) => void][] = [];
|
protected items: [T, (result: R) => void, (error: unknown) => void, void | (() => void)][] = [];
|
||||||
|
|
||||||
constructor(protected maxConcurrent = 5) {}
|
constructor(protected maxConcurrent = 5) {}
|
||||||
|
|
||||||
abstract handler(item: T): Promise<R>;
|
abstract handler(item: T): Promise<R>;
|
||||||
|
|
||||||
public async add(item: T): Promise<R> {
|
public async add(item: T, onPreProcess?: () => void): Promise<R> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.items.push([item, resolve, reject]);
|
this.items.push([item, resolve, reject, onPreProcess]);
|
||||||
this.runWorkerIfFree();
|
this.runWorkerIfFree();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -32,9 +32,11 @@ export abstract class ConcurrentQueue<T, R = unknown> {
|
|||||||
private async processQueue() {
|
private async processQueue() {
|
||||||
let item: (typeof this.items)[0] | void;
|
let item: (typeof this.items)[0] | void;
|
||||||
while ((item = this.items.shift())) {
|
while ((item = this.items.shift())) {
|
||||||
const [payload, resolve, reject] = item;
|
const [payload, resolve, reject, onPreProcess] = item;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
onPreProcess?.();
|
||||||
|
|
||||||
resolve(await this.handler(payload));
|
resolve(await this.handler(payload));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
reject(error);
|
reject(error);
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { DecryptError } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
|
||||||
type WorkerServerHandler<P, R> = (payload: P) => R | Promise<R>;
|
type WorkerServerHandler<P, R> = (payload: P) => R | Promise<R>;
|
||||||
|
|
||||||
interface SerializedError {
|
interface SerializedError {
|
||||||
message: string;
|
message: string;
|
||||||
stack?: string;
|
stack?: string;
|
||||||
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkerClientRequestPayload<P = unknown> {
|
interface WorkerClientRequestPayload<P = unknown> {
|
||||||
@ -39,6 +41,7 @@ export class WorkerClientBus<T = string> {
|
|||||||
if (error) {
|
if (error) {
|
||||||
const wrappedError = new Error(error.message, { cause: error });
|
const wrappedError = new Error(error.message, { cause: error });
|
||||||
wrappedError.stack = error.stack;
|
wrappedError.stack = error.stack;
|
||||||
|
Object.assign(wrappedError, { code: error.code ?? null });
|
||||||
reject(wrappedError);
|
reject(wrappedError);
|
||||||
} else {
|
} else {
|
||||||
resolve(result as never);
|
resolve(result as never);
|
||||||
@ -77,8 +80,12 @@ export class WorkerServerBus {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
result = await handler(payload);
|
result = await handler(payload);
|
||||||
} catch (e: unknown) {
|
} catch (err: unknown) {
|
||||||
error = e;
|
if (err instanceof DecryptError) {
|
||||||
|
error = err.toJSON();
|
||||||
|
} else {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user