diff --git a/README.MD b/README.MD
index aa8766f..1bdfd93 100644
--- a/README.MD
+++ b/README.MD
@@ -4,3 +4,9 @@
pnpm i
pnpm start
```
+
+## TODO
+
+- 文件拖放 (利用 `react-dropzone`?)
+- 各类算法
+- 简易元数据编辑器
diff --git a/index.html b/index.html
index e0d1c84..25f31d9 100644
--- a/index.html
+++ b/index.html
@@ -2,12 +2,11 @@
-
- Vite + React + TS
+ 音乐解锁 - Unlock Music
-
+
diff --git a/package.json b/package.json
index 92b776e..9367414 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,11 @@
"@chakra-ui/react": "^2.6.1",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
+ "@reduxjs/toolkit": "^1.9.5",
"framer-motion": "^10.12.8",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react-redux": "^8.0.5"
},
"devDependencies": {
"@types/react": "^18.0.28",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d757d83..5767ed6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,6 +13,9 @@ dependencies:
'@emotion/styled':
specifier: ^11.11.0
version: 11.11.0(@emotion/react@11.11.0)(@types/react@18.0.28)(react@18.2.0)
+ '@reduxjs/toolkit':
+ specifier: ^1.9.5
+ version: 1.9.5(react-redux@8.0.5)(react@18.2.0)
framer-motion:
specifier: ^10.12.8
version: 10.12.8(react-dom@18.2.0)(react@18.2.0)
@@ -22,6 +25,9 @@ dependencies:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ react-redux:
+ specifier: ^8.0.5
+ version: 8.0.5(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
devDependencies:
'@types/react':
@@ -1818,6 +1824,32 @@ packages:
resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==}
dev: false
+ /@reduxjs/toolkit@1.9.5(react-redux@8.0.5)(react@18.2.0):
+ resolution: {integrity: sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==}
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18
+ react-redux: ^7.2.1 || ^8.0.2
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+ dependencies:
+ immer: 9.0.21
+ react: 18.2.0
+ react-redux: 8.0.5(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
+ redux: 4.2.1
+ redux-thunk: 2.4.2(redux@4.2.1)
+ reselect: 4.1.8
+ dev: false
+
+ /@types/hoist-non-react-statics@3.3.1:
+ resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==}
+ dependencies:
+ '@types/react': 18.0.28
+ hoist-non-react-statics: 3.3.2
+ dev: false
+
/@types/json-schema@7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
@@ -1843,7 +1875,6 @@ packages:
resolution: {integrity: sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==}
dependencies:
'@types/react': 18.0.28
- dev: true
/@types/react@18.0.28:
resolution: {integrity: sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==}
@@ -1859,6 +1890,10 @@ packages:
resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==}
dev: true
+ /@types/use-sync-external-store@0.0.3:
+ resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==}
+ dev: false
+
/@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@5.0.2):
resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2625,6 +2660,10 @@ packages:
engines: {node: '>= 4'}
dev: true
+ /immer@9.0.21:
+ resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
+ dev: false
+
/import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@@ -2978,6 +3017,44 @@ packages:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false
+ /react-is@18.2.0:
+ resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
+ dev: false
+
+ /react-redux@8.0.5(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1):
+ resolution: {integrity: sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==}
+ peerDependencies:
+ '@types/react': ^16.8 || ^17.0 || ^18.0
+ '@types/react-dom': ^16.8 || ^17.0 || ^18.0
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ react-native: '>=0.59'
+ redux: ^4
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ redux:
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.21.5
+ '@types/hoist-non-react-statics': 3.3.1
+ '@types/react': 18.0.28
+ '@types/react-dom': 18.0.11
+ '@types/use-sync-external-store': 0.0.3
+ hoist-non-react-statics: 3.3.2
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ react-is: 18.2.0
+ redux: 4.2.1
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ dev: false
+
/react-refresh@0.14.0:
resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==}
engines: {node: '>=0.10.0'}
@@ -3042,10 +3119,28 @@ packages:
loose-envify: 1.4.0
dev: false
+ /redux-thunk@2.4.2(redux@4.2.1):
+ resolution: {integrity: sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==}
+ peerDependencies:
+ redux: ^4
+ dependencies:
+ redux: 4.2.1
+ dev: false
+
+ /redux@4.2.1:
+ resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
+ dependencies:
+ '@babel/runtime': 7.21.5
+ dev: false
+
/regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
dev: false
+ /reselect@4.1.8:
+ resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
+ dev: false
+
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3276,6 +3371,14 @@ packages:
tslib: 2.5.0
dev: false
+ /use-sync-external-store@1.2.0(react@18.2.0):
+ resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/vite@4.3.2:
resolution: {integrity: sha512-9R53Mf+TBoXCYejcL+qFbZde+eZveQLDYd9XgULILLC1a5ZwPaqgmdVpL8/uvw2BM/1TzetWjglwm+3RO+xTyw==}
engines: {node: ^14.18.0 || >=16.0.0}
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index ae4302b..0000000
--- a/src/App.css
+++ /dev/null
@@ -1 +0,0 @@
-/* empty file here */
diff --git a/src/App.tsx b/src/App.tsx
index 8626ef6..cf4640d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,22 @@
-import './App.css';
+import { Box, Center, Container } from '@chakra-ui/react';
import { SelectFile } from './SelectFile';
+import { FileListing } from './features/file-listing/FileListing';
+import { Footer } from './Footer';
+
function App() {
return (
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/Footer.tsx b/src/Footer.tsx
new file mode 100644
index 0000000..4ef6247
--- /dev/null
+++ b/src/Footer.tsx
@@ -0,0 +1,33 @@
+import { Center, Link, Text } from '@chakra-ui/react';
+
+export function Footer() {
+ return (
+
+
+ 音乐解锁 (x.x.x) - 移除已购音乐的加密保护。
+
+ Copyright © 2019 - 2023{' '}
+
+ UnlockMusic 团队
+ {' '}
+ | 音乐解锁授权基于
+
+ MIT许可协议
+
+ 。
+
+
+
+ );
+}
diff --git a/src/PointerLabel.tsx b/src/PointerLabel.tsx
deleted file mode 100644
index f5fd380..0000000
--- a/src/PointerLabel.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import styled from '@emotion/styled';
-
-export const PointerLabel = styled.label`
- cursor: pointer;
-`;
diff --git a/src/SelectFile.tsx b/src/SelectFile.tsx
index 2d21a51..a961b26 100644
--- a/src/SelectFile.tsx
+++ b/src/SelectFile.tsx
@@ -1,25 +1,44 @@
-import { Box, Stack, Text } from '@chakra-ui/react';
-import { UnlockIcon } from '@chakra-ui/icons';
import { useId } from 'react';
-import { PointerLabel } from './PointerLabel';
+
+import { Box, Text } from '@chakra-ui/react';
+import { UnlockIcon } from '@chakra-ui/icons';
export function SelectFile() {
const id = useId();
return (
-
-
-
-
- 将文件拖到此处,或
-
-
- 点击选择
-
-
-
-
-
+
+
+
+
+
+ {/* 将文件拖到此处,或 */}
+
+ 点我选择
+
+ 需要解密的文件
+
+
+ 仅在浏览器内对文件进行解锁,无需消耗流量
+
+
);
}
diff --git a/src/features/file-listing/FileListing.tsx b/src/features/file-listing/FileListing.tsx
new file mode 100644
index 0000000..d137589
--- /dev/null
+++ b/src/features/file-listing/FileListing.tsx
@@ -0,0 +1,56 @@
+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';
+
+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 (
+
+
+
+
+ 封面 |
+ 元信息 |
+ 操作 |
+
+
+
+ {files.map((file) => (
+
+
+ {file.metadata.cover && }
+ {!file.metadata.cover && 暂无封面}
+ |
+
+
+ {file.metadata.name || file.fileName}
+
+ 专辑: {file.metadata.album}
+ 艺术家: {file.metadata.artist}
+ 专辑艺术家: {file.metadata.albumArtist}
+ |
+
+
+ 播放
+ 下载
+ 删除
+
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts
new file mode 100644
index 0000000..032fe2c
--- /dev/null
+++ b/src/features/file-listing/fileListingSlice.ts
@@ -0,0 +1,79 @@
+import { createSlice } from '@reduxjs/toolkit';
+import type { PayloadAction } from '@reduxjs/toolkit';
+import type { RootState } from '../../store';
+
+export enum ProcessState {
+ UNTOUCHED = 'UNTOUCHED',
+ COMPLETE = 'COMPLETE',
+ ERROR = 'ERROR',
+}
+
+export enum ListingMode {
+ LIST = 'LIST',
+ CARD = 'CARD',
+}
+
+export interface AudioMetadata {
+ name: string;
+ artist: string;
+ album: string;
+ albumArtist: string;
+ cover: string; // blob uri
+}
+
+export interface DecryptedAudioFile {
+ id: string;
+ fileName: string;
+ raw: string; // blob uri
+ decrypted: string; // blob uri
+ state: ProcessState;
+ errorMessage: null | string;
+ metadata: AudioMetadata;
+}
+
+export interface FileListingState {
+ files: DecryptedAudioFile[];
+ displayMode: ListingMode;
+}
+const initialState: FileListingState = {
+ files: [],
+ displayMode: ListingMode.LIST,
+};
+
+export const fileListingSlice = createSlice({
+ name: 'fileListing',
+ initialState,
+ reducers: {
+ addNewFile: (state, { payload }: PayloadAction<{ id: string; fileName: string; blobURI: string }>) => {
+ state.files.push({
+ id: payload.id,
+ fileName: payload.fileName,
+ raw: payload.blobURI,
+ decrypted: '',
+ state: ProcessState.UNTOUCHED,
+ errorMessage: null,
+ metadata: {
+ name: '',
+ artist: '',
+ album: '',
+ albumArtist: '',
+ cover: '',
+ },
+ });
+ },
+ setDecryptedContent: (state, { payload }: PayloadAction<{ id: string; decryptedBlobURI: string }>) => {
+ const file = state.files.find((file) => file.id === payload.id);
+ if (file) {
+ file.decrypted = payload.decryptedBlobURI;
+ }
+ },
+ },
+});
+
+export const { addNewFile, setDecryptedContent } = fileListingSlice.actions;
+
+export const selectFileCount = (state: RootState) => state.fileListing.files.length;
+export const selectFiles = (state: RootState) => state.fileListing.files;
+export const selectFileListingMode = (state: RootState) => state.fileListing.displayMode;
+
+export default fileListingSlice.reducer;
diff --git a/src/hooks.ts b/src/hooks.ts
new file mode 100644
index 0000000..ec02f99
--- /dev/null
+++ b/src/hooks.ts
@@ -0,0 +1,6 @@
+import { useDispatch, useSelector } from 'react-redux';
+import type { TypedUseSelectorHook } from 'react-redux';
+import type { RootState, AppDispatch } from './store';
+
+export const useAppDispatch: () => AppDispatch = useDispatch;
+export const useAppSelector: TypedUseSelectorHook = useSelector;
diff --git a/src/index.css b/src/index.css
deleted file mode 100644
index 2c3fac6..0000000
--- a/src/index.css
+++ /dev/null
@@ -1,69 +0,0 @@
-:root {
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- -webkit-text-size-adjust: 100%;
-}
-
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
-}
-
-body {
- margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
-}
-
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
-}
-
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
-}
diff --git a/src/main.tsx b/src/main.tsx
index 49a671e..edd90f0 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,14 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
-import App from './App.tsx';
-import './index.css';
+import App from './App';
import { ChakraProvider } from '@chakra-ui/react';
+import { Provider } from 'react-redux';
+import { store } from './store';
+import { theme } from './theme';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
-
-
+
+
+
+
);
diff --git a/src/store.ts b/src/store.ts
new file mode 100644
index 0000000..62584b0
--- /dev/null
+++ b/src/store.ts
@@ -0,0 +1,11 @@
+import { configureStore } from '@reduxjs/toolkit';
+import fileListing from './features/file-listing/fileListingSlice';
+
+export const store = configureStore({
+ reducer: {
+ fileListing: fileListing,
+ },
+});
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;
diff --git a/src/theme.ts b/src/theme.ts
new file mode 100644
index 0000000..10724a4
--- /dev/null
+++ b/src/theme.ts
@@ -0,0 +1,17 @@
+import { extendTheme } from '@chakra-ui/react';
+
+export const theme = extendTheme({
+ styles: {
+ global: {
+ body: {
+ minHeight: '100vh',
+ },
+ },
+ },
+ sizes: {
+ footer: {
+ container: '7rem',
+ content: '5rem',
+ },
+ },
+});