Compare commits
6 Commits
37f6667e50
...
6b8731eea6
Author | SHA1 | Date | |
---|---|---|---|
6b8731eea6 | |||
6ab9ef8855 | |||
6bea3c135f | |||
7b09967233 | |||
329ce0c10b | |||
1b507774ed |
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js{x,on,},ts{x,}}]
|
||||
indent_size = 2
|
@ -16,6 +16,7 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"framer-motion": "^10.12.8",
|
||||
"nanoid": "^4.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.5"
|
||||
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -19,6 +19,9 @@ dependencies:
|
||||
framer-motion:
|
||||
specifier: ^10.12.8
|
||||
version: 10.12.8(react-dom@18.2.0)(react@18.2.0)
|
||||
nanoid:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
@ -2843,6 +2846,12 @@ packages:
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/nanoid@4.0.2:
|
||||
resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==}
|
||||
engines: {node: ^14 || ^16 || >=18}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/natural-compare-lite@1.4.0:
|
||||
resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==}
|
||||
dev: true
|
||||
|
@ -1,11 +1,36 @@
|
||||
import { useId } from 'react';
|
||||
import React, { useId } from 'react';
|
||||
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { UnlockIcon } from '@chakra-ui/icons';
|
||||
import { useAppDispatch } from './hooks';
|
||||
import { addNewFile, processFile } from './features/file-listing/fileListingSlice';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export function SelectFile() {
|
||||
const dispatch = useAppDispatch();
|
||||
const id = useId();
|
||||
|
||||
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
for (const file of e.target.files) {
|
||||
const blobURI = URL.createObjectURL(file);
|
||||
const fileName = file.name;
|
||||
const fileId = 'file://' + nanoid();
|
||||
// FIXME: this should be a single action/thunk that first adds the item, then updates it.
|
||||
dispatch(
|
||||
addNewFile({
|
||||
id: fileId,
|
||||
blobURI,
|
||||
fileName,
|
||||
})
|
||||
);
|
||||
dispatch(processFile(fileId));
|
||||
}
|
||||
}
|
||||
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="label"
|
||||
@ -34,7 +59,7 @@ export function SelectFile() {
|
||||
点我选择
|
||||
</Text>
|
||||
需要解密的文件
|
||||
<input id={id} type="file" hidden multiple />
|
||||
<input id={id} type="file" hidden multiple onChange={handleFileSelection} />
|
||||
<Text fontSize="sm" opacity="50%">
|
||||
仅在浏览器内对文件进行解锁,无需消耗流量
|
||||
</Text>
|
||||
|
18
src/decrypt-worker/client.ts
Normal file
18
src/decrypt-worker/client.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { ConcurrentQueue } from '../util/ConcurrentQueue';
|
||||
import { WorkerClientBus } from '../util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
|
||||
// TODO: Worker pool?
|
||||
export const workerClient = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
|
||||
|
||||
class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: string }> {
|
||||
constructor(private workerClientBus: WorkerClientBus, maxQueue?: number) {
|
||||
super(maxQueue);
|
||||
}
|
||||
|
||||
async handler(item: { id: string; blobURI: string }): Promise<void> {
|
||||
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item.blobURI);
|
||||
}
|
||||
}
|
||||
|
||||
export const decryptionQueue = new DecryptionQueue(new WorkerClientBus(workerClient));
|
3
src/decrypt-worker/constants.ts
Normal file
3
src/decrypt-worker/constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||
DECRYPT = 'DECRYPT',
|
||||
}
|
12
src/decrypt-worker/worker.ts
Normal file
12
src/decrypt-worker/worker.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { WorkerServerBus } from '../util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
|
||||
const bus = new WorkerServerBus();
|
||||
onmessage = bus.onmessage;
|
||||
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, async (blobURI) => {
|
||||
const blob = await fetch(blobURI).then((r) => r.arrayBuffer());
|
||||
// TODO: Implement decryptor for blob received here.
|
||||
console.log(blob);
|
||||
return { hello: true };
|
||||
});
|
@ -1,20 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Avatar, Box, Table, TableContainer, Tbody, Td, Text, Th, Thead, Tr, Wrap, WrapItem } from '@chakra-ui/react';
|
||||
|
||||
import { addNewFile, selectFiles } from './fileListingSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks';
|
||||
import { ProcessState, selectFiles } from './fileListingSlice';
|
||||
import { useAppSelector } from '../../hooks';
|
||||
|
||||
export function FileListing() {
|
||||
const dispatch = useAppDispatch();
|
||||
const files = useAppSelector(selectFiles);
|
||||
|
||||
useEffect(() => {
|
||||
// FIXME: Remove test data
|
||||
if (files.length === 0) {
|
||||
dispatch(addNewFile({ id: String(Date.now()), fileName: '测试文件名.mgg', blobURI: '' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table variant="striped">
|
||||
@ -26,8 +17,8 @@ export function FileListing() {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{files.map((file) => (
|
||||
<Tr key={file.id}>
|
||||
{Object.entries(files).map(([id, file]) => (
|
||||
<Tr key={id}>
|
||||
<Td>
|
||||
{file.metadata.cover && <Avatar size="sm" name="专辑封面" src={file.metadata.cover} />}
|
||||
{!file.metadata.cover && <Text>暂无封面</Text>}
|
||||
@ -36,9 +27,13 @@ export function FileListing() {
|
||||
<Box as="h4" fontWeight="semibold" mt="1">
|
||||
{file.metadata.name || file.fileName}
|
||||
</Box>
|
||||
<Text>专辑: {file.metadata.album}</Text>
|
||||
<Text>艺术家: {file.metadata.artist}</Text>
|
||||
<Text>专辑艺术家: {file.metadata.albumArtist}</Text>
|
||||
{file.state === ProcessState.COMPLETE && (
|
||||
<>
|
||||
<Text>专辑: {file.metadata.album}</Text>
|
||||
<Text>艺术家: {file.metadata.artist}</Text>
|
||||
<Text>专辑艺术家: {file.metadata.albumArtist}</Text>
|
||||
</>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Wrap>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '../../store';
|
||||
import { decryptionQueue } from '../../decrypt-worker/client';
|
||||
|
||||
export enum ProcessState {
|
||||
UNTOUCHED = 'UNTOUCHED',
|
||||
@ -22,7 +23,6 @@ export interface AudioMetadata {
|
||||
}
|
||||
|
||||
export interface DecryptedAudioFile {
|
||||
id: string;
|
||||
fileName: string;
|
||||
raw: string; // blob uri
|
||||
decrypted: string; // blob uri
|
||||
@ -32,21 +32,29 @@ export interface DecryptedAudioFile {
|
||||
}
|
||||
|
||||
export interface FileListingState {
|
||||
files: DecryptedAudioFile[];
|
||||
files: Record<string, DecryptedAudioFile>;
|
||||
displayMode: ListingMode;
|
||||
}
|
||||
const initialState: FileListingState = {
|
||||
files: [],
|
||||
files: Object.create(null),
|
||||
displayMode: ListingMode.LIST,
|
||||
};
|
||||
|
||||
export const processFile = createAsyncThunk('fileListing/processFile', async (fileId: string, thunkAPI) => {
|
||||
const file = selectFiles(thunkAPI.getState() as RootState)[fileId];
|
||||
if (!file) {
|
||||
return thunkAPI.rejectWithValue('ERROR: File not found');
|
||||
}
|
||||
|
||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw });
|
||||
});
|
||||
|
||||
export const fileListingSlice = createSlice({
|
||||
name: 'fileListing',
|
||||
initialState,
|
||||
reducers: {
|
||||
addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
|
||||
state.files.push({
|
||||
id: payload.id,
|
||||
state.files[payload.id] = {
|
||||
fileName: payload.fileName,
|
||||
raw: payload.blobURI,
|
||||
decrypted: '',
|
||||
@ -59,10 +67,10 @@ export const fileListingSlice = createSlice({
|
||||
albumArtist: '',
|
||||
cover: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
setDecryptedContent: (state, { payload }: PayloadAction<{ id: string; decryptedBlobURI: string }>) => {
|
||||
const file = state.files.find((file) => file.id === payload.id);
|
||||
const file = state.files[payload.id];
|
||||
if (file) {
|
||||
file.decrypted = payload.decryptedBlobURI;
|
||||
}
|
||||
|
40
src/util/ConcurrentQueue.ts
Normal file
40
src/util/ConcurrentQueue.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export abstract class ConcurrentQueue<T> {
|
||||
protected items: [T, (result: any) => void, (error: Error) => void][] = [];
|
||||
protected currentlyWorking = 0;
|
||||
|
||||
constructor(protected maxQueue = 5) {}
|
||||
|
||||
abstract handler(item: T): Promise<void>;
|
||||
|
||||
public async add<R = never>(item: T): Promise<R> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.items.push([item, resolve, reject]);
|
||||
this.runWorkerIfFree();
|
||||
});
|
||||
}
|
||||
|
||||
private runWorkerIfFree() {
|
||||
if (this.currentlyWorking < this.maxQueue) {
|
||||
this.currentlyWorking++;
|
||||
this.processQueue()
|
||||
.catch((e) => {
|
||||
console.error('process queue with error', e);
|
||||
})
|
||||
.finally(() => {
|
||||
this.currentlyWorking--;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
while (true) {
|
||||
const item = this.items.pop();
|
||||
if (item === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const [payload, resolve, reject] = item;
|
||||
await this.handler(payload).then(resolve).catch(reject);
|
||||
}
|
||||
}
|
||||
}
|
62
src/util/WorkerEventBus.ts
Normal file
62
src/util/WorkerEventBus.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export class WorkerClientBus {
|
||||
private idPromiseMap = new Map<string, [(data: any) => void, (error: Error) => void]>();
|
||||
|
||||
constructor(private worker: Worker) {
|
||||
worker.addEventListener('message', (e) => {
|
||||
const { id, result, error } = e.data;
|
||||
const actionPromise = this.idPromiseMap.get(id);
|
||||
if (!actionPromise) {
|
||||
console.error('cound not fetch worker promise for action: %s', id);
|
||||
return;
|
||||
}
|
||||
this.idPromiseMap.delete(id);
|
||||
|
||||
const [resolve, reject] = actionPromise;
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async request<R = any, P = any>(actionName: string, payload: P): Promise<R> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nanoid();
|
||||
this.idPromiseMap.set(id, [resolve, reject]);
|
||||
this.worker.postMessage({
|
||||
id,
|
||||
action: actionName,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkerServerBus {
|
||||
private handlers = new Map<string, (payload: any) => Promise<any>>();
|
||||
|
||||
addEventHandler<R = any, P = any>(actionName: string, handler: (payload: P) => Promise<R>) {
|
||||
this.handlers.set(actionName, handler);
|
||||
}
|
||||
|
||||
onmessage = async (e: MessageEvent<any>) => {
|
||||
const { id, action, payload } = e.data;
|
||||
const handler = this.handlers.get(action);
|
||||
if (!handler) {
|
||||
postMessage({ id, result: null, error: new Error('Handler missing for action ' + action) });
|
||||
return;
|
||||
}
|
||||
|
||||
let result = undefined;
|
||||
let error = undefined;
|
||||
try {
|
||||
result = await handler(payload);
|
||||
} catch (e: unknown) {
|
||||
error = e;
|
||||
}
|
||||
postMessage({ id, result, error });
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user