├── .editorconfig ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── issue_label_bot.yaml ├── stale.yml └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── .vim └── coc-settings.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-workspace-tools.cjs ├── releases │ └── yarn-4.2.2.cjs └── sdks │ ├── eslint │ ├── bin │ │ └── eslint.js │ ├── lib │ │ ├── api.js │ │ └── unsupported-api.js │ └── package.json │ ├── integrations.yml │ ├── prettier │ ├── bin │ │ └── prettier.cjs │ ├── index.cjs │ └── package.json │ └── typescript │ ├── bin │ ├── tsc │ └── tsserver │ ├── lib │ ├── tsc.js │ ├── tsserver.js │ ├── tsserverlibrary.js │ └── typescript.js │ └── package.json ├── .yarnrc.yml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── LICENSE ├── README.EN.md ├── README.md ├── crates ├── macmedia │ ├── Cargo.toml │ ├── build.rs │ └── src │ │ └── main.rs ├── native │ ├── Cargo.toml │ ├── build.rs │ └── src │ │ ├── keyboard.rs │ │ ├── lib.rs │ │ ├── media.rs │ │ └── player.rs ├── wasi │ ├── Cargo.toml │ └── src │ │ ├── kuwo_des.rs │ │ └── lib.rs └── wasm │ ├── Cargo.toml │ └── src │ └── lib.rs ├── deno.json ├── deno.lock ├── doc ├── API.md └── usage.png ├── docker ├── Dockerfile.aarch64-unknown-linux-gnu └── Dockerfile.armv7-unknown-linux-gnueabihf ├── media ├── audio │ ├── silent.flac │ └── silent.mp3 ├── disc.svg ├── icon-150.png ├── icon-300.png ├── icon-72.png ├── icon.ico ├── icon.svg └── walkthroughs │ ├── queue.md │ ├── statusBar-compact.png │ ├── statusBar-normal.png │ └── statusBar.md ├── package.json ├── package.nls.json ├── package.nls.zh-cn.json ├── package.nls.zh-tw.json ├── packages ├── @types │ ├── api │ │ ├── index.d.ts │ │ ├── netease.d.ts │ │ └── package.json │ ├── qrcode │ │ ├── index.d.ts │ │ └── package.json │ └── tsconfig.json ├── client │ ├── package.json │ ├── src │ │ ├── activate │ │ │ ├── account.ts │ │ │ ├── cache.ts │ │ │ ├── command.ts │ │ │ ├── index.ts │ │ │ ├── ipc.ts │ │ │ ├── local.ts │ │ │ ├── playlist.ts │ │ │ ├── queue.ts │ │ │ ├── radio.ts │ │ │ └── statusBar.ts │ │ ├── constant │ │ │ ├── index.ts │ │ │ └── shared.ts │ │ ├── extension.ts │ │ ├── i18n │ │ │ ├── en.ts │ │ │ ├── index.ts │ │ │ ├── zh-cn.ts │ │ │ └── zh-tw.ts │ │ ├── manager │ │ │ ├── account.ts │ │ │ ├── button.ts │ │ │ └── index.ts │ │ ├── treeview │ │ │ ├── index.ts │ │ │ ├── local.ts │ │ │ ├── playlist.ts │ │ │ ├── queue.ts │ │ │ ├── radio.ts │ │ │ └── user.ts │ │ ├── unblock │ │ │ ├── filter.ts │ │ │ ├── index.ts │ │ │ ├── joox.ts │ │ │ ├── kugou.ts │ │ │ ├── kuwo.ts │ │ │ ├── migu.ts │ │ │ └── qq.ts │ │ └── utils │ │ │ ├── index.ts │ │ │ ├── ipc.ts │ │ │ ├── multiStepInput.ts │ │ │ ├── search.ts │ │ │ ├── state.ts │ │ │ ├── util.ts │ │ │ └── webview.ts │ └── tsconfig.json ├── server │ ├── package.json │ ├── src │ │ ├── api │ │ │ ├── helper.ts │ │ │ ├── index.ts │ │ │ └── netease │ │ │ │ ├── account.ts │ │ │ │ ├── album.ts │ │ │ │ ├── artist.ts │ │ │ │ ├── comment.ts │ │ │ │ ├── crypto.ts │ │ │ │ ├── djradio.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mv.ts │ │ │ │ ├── playlist.ts │ │ │ │ ├── request.ts │ │ │ │ ├── search.ts │ │ │ │ └── song.ts │ │ ├── cache.ts │ │ ├── constant.ts │ │ ├── index.ts │ │ ├── player.ts │ │ ├── server.ts │ │ ├── state.ts │ │ └── utils.ts │ └── tsconfig.json ├── shared │ ├── package.json │ ├── src │ │ ├── api │ │ │ ├── index.ts │ │ │ └── netease.ts │ │ ├── constant.ts │ │ ├── event.ts │ │ ├── index.ts │ │ ├── net.ts │ │ └── webview.ts │ └── tsconfig.json ├── wasi │ └── package.json ├── wasm │ └── package.json └── webview │ ├── package.json │ ├── src │ ├── components │ │ ├── comment.tsx │ │ ├── index.ts │ │ ├── musicCard.tsx │ │ └── tabs.tsx │ ├── entries │ │ ├── comment.tsx │ │ ├── description.tsx │ │ ├── login.tsx │ │ ├── lyric.tsx │ │ ├── musicRanking.tsx │ │ ├── provider.tsx │ │ └── video.tsx │ ├── i18n │ │ ├── en.ts │ │ └── index.ts │ ├── pages │ │ ├── comment.tsx │ │ ├── description.tsx │ │ ├── index.ts │ │ ├── login.tsx │ │ ├── lyric.tsx │ │ ├── musicRanking.tsx │ │ └── video.tsx │ └── utils │ │ ├── index.ts │ │ └── net.ts │ └── tsconfig.json ├── renovate.json ├── scripts ├── build.mts └── publish.mts ├── tailwind.config.cjs └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | end_of_line = lf 8 | 9 | [*.rs] 10 | indent_size = 4 11 | 12 | [*.toml] 13 | indent_size = 4 -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @type {Partial} 5 | */ 6 | const baseNodeTsRules = { 7 | "@typescript-eslint/consistent-type-imports": "warn", 8 | "@typescript-eslint/lines-between-class-members": "error", 9 | "@typescript-eslint/member-ordering": "warn", 10 | "@typescript-eslint/naming-convention": "warn", 11 | "@typescript-eslint/semi": "warn", 12 | "no-empty": ["error", { allowEmptyCatch: true }], 13 | }; 14 | 15 | /** 16 | * @param {string} files - 17 | * @param {string} project - 18 | * @returns {import('eslint').Linter.ConfigOverride} - 19 | */ 20 | const nodeTsConfig = (files, project) => ({ 21 | extends: [ 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 25 | "plugin:prettier/recommended", 26 | ], 27 | files, 28 | parserOptions: { 29 | ecmaVersion: 10, 30 | project: `${__dirname}${project}`, 31 | sourceType: "module", 32 | }, 33 | plugins: ["@typescript-eslint", "prettier"], 34 | rules: baseNodeTsRules, 35 | }); 36 | 37 | /** 38 | * @param {string} files - 39 | * @param {string} project - 40 | * @returns {import('eslint').Linter.ConfigOverride} - 41 | */ 42 | const browserTsConfig = (files, project) => ({ 43 | env: { browser: true, node: false }, 44 | extends: [ 45 | "eslint:recommended", 46 | "plugin:@typescript-eslint/recommended", 47 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 48 | "plugin:prettier/recommended", 49 | "plugin:react/recommended", 50 | "plugin:react-hooks/recommended", 51 | ], 52 | files, 53 | parserOptions: { 54 | ecmaVersion: 10, 55 | project: `${__dirname}${project}`, 56 | sourceType: "module", 57 | }, 58 | plugins: ["@typescript-eslint", "prettier", "react"], 59 | rules: { 60 | ...baseNodeTsRules, 61 | "react/jsx-uses-react": "off", 62 | "react/react-in-jsx-scope": "off", 63 | }, 64 | settings: { react: { version: "17.0" } }, 65 | }); 66 | 67 | /**@type {import('eslint').Linter.Config}*/ 68 | const config = { 69 | env: { browser: false, node: true }, 70 | extends: ["eslint:recommended", "plugin:prettier/recommended"], 71 | ignorePatterns: ["/packages/wasi/**/*.*", "/packages/wasm/**/*.*", "/scripts/**/*.*", "*.json"], 72 | parser: "@typescript-eslint/parser", 73 | plugins: ["prettier"], 74 | rules: { 75 | curly: "warn", 76 | eqeqeq: "warn", 77 | "no-empty": ["error", { allowEmptyCatch: true }], 78 | "no-throw-literal": "warn", 79 | semi: "warn", 80 | "sort-imports": [ 81 | "warn", 82 | { 83 | allowSeparatedGroups: false, 84 | ignoreCase: false, 85 | ignoreDeclarationSort: false, 86 | ignoreMemberSort: false, 87 | memberSyntaxSortOrder: ["none", "all", "multiple", "single"], 88 | }, 89 | ], 90 | }, 91 | overrides: [ 92 | nodeTsConfig("./packages/@types/**/*.ts", "/packages/@types/tsconfig.json"), 93 | nodeTsConfig("./packages/client/**/*.ts", "/packages/client/tsconfig.json"), 94 | nodeTsConfig("./packages/server/**/*.ts", "/packages/server/tsconfig.json"), 95 | nodeTsConfig("./packages/shared/**/*.ts", "/packages/shared/tsconfig.json"), 96 | browserTsConfig("./packages/webview/**/*.ts*", "/packages/webview/tsconfig.json"), 97 | ], 98 | }; 99 | 100 | module.exports = config; 101 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: cloudmusic-vscode 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | # custom: ["https://afdian.net/@yxl76"] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 清楚简明地描述了该错误是什么。 12 | 13 | **Error information** 14 | Paste or screenshot the content in `Help->Toggle Developer Tools->Console`. 15 | 粘贴或截屏`帮助->切换开发者工具->控制台`中的内容。 16 | 17 | **Server logs** 18 | Execute `cloudmusic.openLogFile`. 19 | 执行 `cloudmusic.openLogFile`。 20 | 21 | **Environment** 22 | Paste the content in `Help->About`. 23 | 粘贴`帮助->关于`中的内容。 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]. 11 | 清楚简洁地说明问题所在。例如,当[...]时,我总是感到沮丧。 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 简洁明了地描述您想要的功能。 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 对您考虑过的所有解决方案或功能的简洁明了的描述。 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | 在此处添加有关功能请求的其他任何内容或屏幕截图。 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Questions about use 4 | title: "" 5 | labels: question 6 | assignees: "" 7 | --- 8 | 9 | **Describe the problem you encountered.** 10 | A clear and concise description of what the question is. 11 | 清楚简洁地说明问题。 12 | 13 | **Error information** 14 | Paste or screenshot the content in `Help->Toggle Developer Tools->Console`. 15 | 粘贴或截屏`帮助->切换开发者工具->控制台`中的内容。 16 | 17 | **Server logs** 18 | Execute `cloudmusic.openLogFile`. 19 | 执行 `cloudmusic.openLogFile`。 20 | 21 | **Environment** 22 | Paste the content in `Help->About`. 23 | 粘贴`帮助->关于`中的内容。 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | ignore: 13 | - dependency-name: "@types/node" 14 | - dependency-name: "@types/vscode" 15 | 16 | - package-ecosystem: "cargo" 17 | directory: "/" 18 | schedule: 19 | interval: "monthly" 20 | -------------------------------------------------------------------------------- /.github/issue_label_bot.yaml: -------------------------------------------------------------------------------- 1 | label-alias: 2 | bug: "bug" 3 | feature_request: "enhancement" 4 | question: "question" 5 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: ["packages/*"] 7 | pull_request: 8 | branches: [master] 9 | paths: ["packages/*"] 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | # Override automatic language detection by changing the below list 20 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 21 | language: ["javascript"] 22 | # Learn more... 23 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | # We must fetch at least the immediate parents so that if this is 30 | # a pull request then we can checkout the head. 31 | fetch-depth: 2 32 | 33 | # If this run was triggered by a pull request event, then checkout 34 | # the head of the pull request instead of the merge commit. 35 | - run: git checkout HEAD^2 36 | if: ${{ github.event_name == 'pull_request' }} 37 | 38 | # Initializes the CodeQL tools for scanning. 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@v3 41 | with: 42 | languages: ${{ matrix.language }} 43 | 44 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 45 | # If this step fails, then you should remove it and run the build manually (see below) 46 | - name: Autobuild 47 | uses: github/codeql-action/autobuild@v3 48 | 49 | # ℹ️ Command-line programs to run using the OS shell. 50 | # 📚 https://git.io/JvXDl 51 | 52 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 53 | # and modify them (or add more) to build your code if your project 54 | # uses a compiled language 55 | 56 | #- run: | 57 | # make bootstrap 58 | # make release 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@v3 62 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | name: Test 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Get yarn cache directory path 20 | id: yarn-cache-dir-path 21 | run: echo "::set-output name=dir::$(yarn config get cacheFolder)" 22 | - uses: actions/cache@v4 23 | id: yarn-cache 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - uses: actions/cache@v4 31 | id: cargo-cache 32 | with: 33 | path: | 34 | ~/.cargo/bin/ 35 | ~/.cargo/registry/index/ 36 | ~/.cargo/registry/cache/ 37 | ~/.cargo/git/db/ 38 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 39 | 40 | # - name: Setup nightly rust 41 | # run: rustup default nightly 42 | 43 | - name: Install rust target 44 | run: rustup target add wasm32-unknown-unknown 45 | 46 | - name: Install wasm-pack 47 | run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh 48 | 49 | - name: Build wasm 50 | run: wasm-pack build crates/wasm/ --dev --out-dir ../../packages/wasm --out-name index 51 | 52 | - run: yarn 53 | - name: Check 54 | run: yarn check 55 | - name: Lint 56 | run: yarn lint 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode-test/ 2 | node_modules 3 | .DS_Store 4 | 5 | !packages/wasi/package.json 6 | !packages/wasm/package.json 7 | 8 | # yarn 9 | .yarn/* 10 | !.yarn/releases 11 | !.yarn/plugins 12 | !.yarn/sdks 13 | !.yarn/versions 14 | .pnp.* 15 | yarn-error.log 16 | 17 | # native module 18 | target 19 | index.node 20 | artifacts.json 21 | crates/macmedia/media 22 | 23 | # cache and build file 24 | .cache 25 | .artifact 26 | build 27 | out 28 | dist 29 | **/*.vsix -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.packageManager": "yarn", 3 | "eslint.nodePath": ".yarn/sdks", 4 | "tsserver.tsdk": ".yarn/sdks/typescript/lib", 5 | "workspace.workspaceFolderCheckCwd": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "arcanis.vscode-zipfs", 7 | "esbenp.prettier-vscode", 8 | "denoland.vscode-deno" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension without building", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"] 13 | }, 14 | { 15 | "name": "Run Extension", 16 | "type": "extensionHost", 17 | "request": "launch", 18 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 19 | "preLaunchTask": "npm: build-dev" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 4 | }, 5 | "[typescript]": { 6 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 7 | }, 8 | "[typescriptreact]": { 9 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 10 | }, 11 | "deno.config": "./deno.json", 12 | "deno.enablePaths": [ 13 | "./scripts" 14 | ], 15 | "eslint.nodePath": ".yarn/sdks", 16 | "files.exclude": { 17 | "out": false 18 | }, 19 | "files.watcherExclude": { 20 | "**/.cache/**": true, 21 | "**/.git/objects/**": true, 22 | "**/.git/subtree-cache/**": true, 23 | "**/.hg/store/**": true, 24 | "**/.yarn/**": true, 25 | "**/dist/**": true, 26 | "**/node_modules/**": true, 27 | "**/target/**": true 28 | }, 29 | "markdownlint.config": { 30 | "MD024": false 31 | }, 32 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 33 | "search.exclude": { 34 | ".cache": true, 35 | ".yarn": true, 36 | "**/.pnp.*": true, 37 | "**/.yarn": true, 38 | "**/*.lock": true, 39 | "build": true, 40 | "dist": true, 41 | "out": true 42 | }, 43 | "typescript.enablePromptUseWorkspaceTsdk": true, 44 | "typescript.tsc.autoDetect": "off", 45 | "typescript.tsdk": ".yarn/sdks/typescript/lib" 46 | } 47 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "shell", 8 | "label": "cargo: update", 9 | "command": "cargo update", 10 | "group": { 11 | "kind": "build" 12 | } 13 | }, 14 | { 15 | "type": "shell", 16 | "label": "cargo: build macmedia", 17 | "command": "cargo build --release --package cloudmusic-macmedia", 18 | "group": { 19 | "kind": "build" 20 | } 21 | }, 22 | { 23 | "type": "shell", 24 | "label": "cargo: build", 25 | "command": "cargo build --release", 26 | "group": { 27 | "kind": "build" 28 | } 29 | }, 30 | { 31 | "type": "shell", 32 | "label": "yarn: update", 33 | "command": "rm yarn.lock && yarn && yarn dlx @yarnpkg/sdks vscode vim", 34 | "group": { 35 | "kind": "build" 36 | } 37 | }, 38 | { 39 | "type": "shell", 40 | "label": "yarn: self-update", 41 | "command": "yarn set version canary", 42 | "group": { 43 | "kind": "build" 44 | } 45 | }, 46 | { 47 | "type": "shell", 48 | "label": "yarn: check", 49 | "command": "yarn check", 50 | "group": { 51 | "kind": "build" 52 | } 53 | }, 54 | { 55 | "type": "shell", 56 | "label": "yarn: lint", 57 | "command": "yarn lint", 58 | "group": { 59 | "kind": "build" 60 | } 61 | }, 62 | { 63 | "type": "shell", 64 | "label": "yarn: package", 65 | "command": "yarn build-dev && yarn dlx @vscode/vsce package --no-dependencies", 66 | "group": { 67 | "kind": "build" 68 | } 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode-test 2 | **/*.map 3 | node_modules 4 | 5 | # yarn 6 | .yarn 7 | .pnp.* 8 | .yarnrc.yml 9 | yarn-error.log 10 | yarn.lock 11 | 12 | # configuration 13 | .cargo 14 | .github 15 | **/gulpfile.js 16 | **/renovate.json 17 | **/webpack.config.js 18 | **/.gitignore 19 | **/.editorconfig 20 | **/tsconfig.json 21 | **/.eslintrc.* 22 | **/.browserslistrc 23 | **/postcss.config.* 24 | **/tailwind.config.* 25 | Cargo.* 26 | docker/ 27 | Cross.toml 28 | deno.json 29 | 30 | ## editors 31 | .vim 32 | .vscode 33 | 34 | # source code 35 | scripts 36 | packages 37 | crates 38 | *.rs 39 | 40 | # cache and build file 41 | .cache 42 | .artifact 43 | out 44 | **/*.vsix 45 | target 46 | index.node 47 | 48 | # document 49 | doc 50 | vsc-extension-quickstart.md 51 | media/icon.svg 52 | media/icon-72.png 53 | media/icon-150.png 54 | **/*.LICENSE.txt 55 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/bin/eslint.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/bin/eslint.js your application uses 20 | module.exports = absRequire(`eslint/bin/eslint.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/unsupported-api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/use-at-your-own-risk 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/use-at-your-own-risk your application uses 20 | module.exports = absRequire(`eslint/use-at-your-own-risk`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.57.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "eslint": "./bin/eslint.js" 8 | }, 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": "./lib/api.js", 12 | "./use-at-your-own-risk": "./lib/unsupported-api.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | - vim 7 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/bin/prettier.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier/bin/prettier.cjs 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier/bin/prettier.cjs your application uses 20 | module.exports = absRequire(`prettier/bin/prettier.cjs`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier your application uses 20 | module.exports = absRequire(`prettier`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "3.2.5-sdk", 4 | "main": "./index.cjs", 5 | "type": "commonjs", 6 | "bin": "./bin/prettier.cjs" 7 | } 8 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript your application uses 20 | module.exports = absRequire(`typescript`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.4.5-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "tsc": "./bin/tsc", 8 | "tsserver": "./bin/tsserver" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | defaultSemverRangePrefix: "" 2 | 3 | enableTelemetry: false 4 | 5 | packageExtensions: 6 | node-cache@*: 7 | dependencies: 8 | "@types/node": "*" 9 | ovsx@*: 10 | dependencies: 11 | semver: "*" 12 | tailwindcss@*: 13 | peerDependenciesMeta: 14 | autoprefixer: 15 | optional: true 16 | 17 | yarnPath: .yarn/releases/yarn-4.2.2.cjs 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/macmedia", "crates/native", "crates/wasi", "crates/wasm"] 3 | default-members = ["crates/native"] 4 | resolver = "2" 5 | 6 | [patch.crates-io] 7 | symphonia = { git = "https://github.com/pdeljanov/Symphonia", branch = "master" } 8 | # minimp3 = { git = "https://github.com/YXL76/minimp3-rs", branch = "dev" } 9 | cpal = { git = "https://github.com/sidit77/cpal", branch = "master" } 10 | 11 | [profile.release] 12 | codegen-units = 1 13 | lto = true 14 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | image = "cross/aarch64:v1" 3 | 4 | [target.aarch64-unknown-linux-gnu.env] 5 | passthrough = ["RUSTFLAGS"] 6 | 7 | [target.armv7-unknown-linux-gnueabihf] 8 | image = "cross/armv7:v1" 9 | 10 | [target.armv7-unknown-linux-gnueabihf.env] 11 | passthrough = ["RUSTFLAGS"] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 兰陈昕 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.EN.md: -------------------------------------------------------------------------------- 1 |

2 | CLOUDMUSIC 3 |

4 | 🎶 CLOUDMUSIC 5 |

6 |

7 | 8 |
9 | 10 | > Netease Music for VS Code 11 | 12 | [![Marketplace](https://img.shields.io/visual-studio-marketplace/v/yxl.cloudmusic.svg?label=Marketplace&style=for-the-badge&logo=visual-studio-code)](https://marketplace.visualstudio.com/items?itemName=yxl.cloudmusic) 13 | [![Installs](https://img.shields.io/visual-studio-marketplace/i/yxl.cloudmusic.svg?style=for-the-badge)](https://marketplace.visualstudio.com/items?itemName=yxl.cloudmusic) 14 | [![Rating](https://img.shields.io/visual-studio-marketplace/stars/yxl.cloudmusic.svg?style=for-the-badge)](https://marketplace.visualstudio.com/items?itemName=yxl.cloudmusic) 15 | 16 | [简体中文](./README.md) | English 17 | 18 |
19 | 20 | ## Table of contents 21 | 22 | - [Table of contents](#table-of-contents) 23 | - [Features](#features) 24 | - [Requirements](#requirements) 25 | - [Usage](#usage) 26 | - [Contributions](#contributions) 27 | - [Release Notes](#release-notes) 28 | - [API](#api) 29 | - [Acknowledgements](#acknowledgements) 30 | 31 | ## Features 32 | 33 | - Simple: out of the box, no need to install or modify any files 34 | - Fast: using native modules, low resource usage and high speed 35 | - Powerful: With the help of web API, all common functions can be realized 36 | 37 | Realized functions: 38 | 39 | - Daily check 40 | - Play, save, like songs 41 | - Listen check 42 | - Intelligent mode 43 | - Personal FM 44 | - Comments (single/playlist...) 45 | - Lyric display 46 | - ~~Unblock copyrighted music~~ 47 | - Search (hot/single/album/artist...) 48 | - Top list (Music List/Singer List...) 49 | - Explore (New song express/New discs on shelves...) 50 | - Radio/Program 51 | - Local library 52 | - Cache management 53 | - Optional lossless music 54 | - Media control support 55 | - More features waiting to be discovered 56 | 57 | All local content generated by the extension is located in `$HOME/.cloudmusic` 58 | 59 | ## Requirements 60 | 61 | ## Usage 62 | 63 | ![Usage](https://z3.ax1x.com/2021/07/31/WjLd61.png) 64 | 65 | Support foreign user, you can enable it in the settings (`cloudmusic.network.foreignUser`) 66 | 67 | ## Contributions 68 | 69 | Full list in `Feature Contributions` 70 | 71 | ## Release Notes 72 | 73 | [CHANGELOG](./CHANGELOG.md) 74 | 75 | ## API 76 | 77 | [API](./doc/API.md) 78 | 79 | ## Acknowledgements 80 | 81 | Inspired by: 82 | 83 | - [swdc-vscode-musictime](https://github.com/swdotcom/swdc-vscode-musictime) 84 | - [vsc-netease-music](https://github.com/nondanee/vsc-netease-music) 85 | - [netease-music-tui](https://github.com/betta-cyber/netease-music-tui) 86 | 87 | Thanks for these awesome projects: 88 | 89 | - [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 90 | - [neon](https://github.com/neon-bindings/neon) 91 | - [rodio](https://github.com/RustAudio/rodio) 92 | - [UnblockNeteaseMusic](https://github.com/nondanee/UnblockNeteaseMusic) 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | CLOUDMUSIC 3 |

4 | 🎶 CLOUDMUSIC 5 |

6 |

7 | 8 |
9 | 10 | > Netease Music for VS Code 11 | 12 | [![Marketplace](https://img.shields.io/visual-studio-marketplace/v/yxl.cloudmusic.svg?label=Marketplace&style=for-the-badge&logo=visual-studio-code)](https://marketplace.visualstudio.com/items?itemName=yxl.cloudmusic) 13 | [![Installs](https://img.shields.io/visual-studio-marketplace/i/yxl.cloudmusic.svg?style=for-the-badge)](https://marketplace.visualstudio.com/items?itemName=yxl.cloudmusic) 14 | [![Rating](https://img.shields.io/visual-studio-marketplace/stars/yxl.cloudmusic.svg?style=for-the-badge)](https://marketplace.visualstudio.com/items?itemName=yxl.cloudmusic) 15 | 16 | 简体中文 | [English](./README.EN.md) 17 | 18 |
19 | 20 | ## Table of contents 21 | 22 | - [Table of contents](#table-of-contents) 23 | - [Features](#features) 24 | - [Requirements](#requirements) 25 | - [Usage](#usage) 26 | - [Contributions](#contributions) 27 | - [Release Notes](#release-notes) 28 | - [API](#api) 29 | - [Acknowledgements](#acknowledgements) 30 | 31 | ## Features 32 | 33 | - 简单:开箱即用,无需安装、修改任何文件 34 | - 快速:使用本机模块,资源占用低,速度快 35 | - 强大:借助网页 API,能实现所有常用功能 36 | 37 | 已实现的功能: 38 | 39 | - 每日签到 40 | - 歌曲播放,收藏,喜欢 41 | - 听歌打卡 42 | - 心动模式 43 | - 私人 FM 44 | - 评论(单曲/歌单...) 45 | - 歌词显示 46 | - ~~解锁变灰音乐~~ 47 | - 搜索(热搜/单曲/专辑/歌手...) 48 | - 排行榜(音乐榜/歌手榜...) 49 | - 发现(新歌速递/新碟上架...) 50 | - 播单/节目 51 | - 本地曲库 52 | - 缓存管理 53 | - 可选无损音质 54 | - 媒体控制支持 55 | - 更多功能等待发现 56 | 57 | 扩展产生的所有本地内容都位于`$HOME/.cloudmusic`文件夹中 58 | 59 | ## Requirements 60 | 61 | ## Usage 62 | 63 | ![Usage](https://z3.ax1x.com/2021/07/31/WjLd61.png) 64 | 65 | 支持海外用户,可以在设置中开启(`cloudmusic.network.foreignUser`) 66 | 67 | ## Contributions 68 | 69 | 完整列表请查看`Feature Contributions` 70 | 71 | ## Release Notes 72 | 73 | [CHANGELOG](./CHANGELOG.md) 74 | 75 | ## API 76 | 77 | [API](./doc/API.md) 78 | 79 | ## Acknowledgements 80 | 81 | 受到以下项目的启发: 82 | 83 | - [swdc-vscode-musictime](https://github.com/swdotcom/swdc-vscode-musictime) 84 | - [vsc-netease-music](https://github.com/nondanee/vsc-netease-music) 85 | - [netease-music-tui](https://github.com/betta-cyber/netease-music-tui) 86 | 87 | 感谢这些超棒的项目: 88 | 89 | - [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 90 | - [neon](https://github.com/neon-bindings/neon) 91 | - [rodio](https://github.com/RustAudio/rodio) 92 | - [UnblockNeteaseMusic](https://github.com/nondanee/UnblockNeteaseMusic) 93 | -------------------------------------------------------------------------------- /crates/macmedia/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cloudmusic-macmedia" 3 | version = "0.1.0" 4 | authors = ["YXL "] 5 | build = "build.rs" 6 | edition = "2021" 7 | 8 | [dependencies] 9 | souvlaki = "0.7.3" 10 | winit = "0.30.0" 11 | -------------------------------------------------------------------------------- /crates/macmedia/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env::var_os, path::PathBuf}; 2 | 3 | #[inline] 4 | fn env_to_path(env_var: &str) -> PathBuf { 5 | PathBuf::from(var_os(env_var).unwrap()) 6 | } 7 | 8 | fn main() { 9 | let output_file = env_to_path("CARGO_MANIFEST_DIR").join("media"); 10 | 11 | println!("cargo:rustc-link-arg-bins=-o"); 12 | println!("cargo:rustc-link-arg-bins={}", output_file.display()); 13 | } 14 | -------------------------------------------------------------------------------- /crates/macmedia/src/main.rs: -------------------------------------------------------------------------------- 1 | use { 2 | souvlaki::{ 3 | MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, 4 | PlatformConfig, 5 | }, 6 | std::{io::stdin, thread, time::Duration}, 7 | winit::{ 8 | application::ApplicationHandler, 9 | event::WindowEvent, 10 | event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, 11 | window::WindowId, 12 | }, 13 | }; 14 | 15 | #[derive(Debug)] 16 | enum CustomEvent { 17 | Media(MediaControlEvent), 18 | Metadata(String), 19 | Playing(String), 20 | } 21 | 22 | struct App { 23 | controls: MediaControls, 24 | } 25 | 26 | impl ApplicationHandler for App { 27 | fn resumed(&mut self, _: &ActiveEventLoop) {} 28 | 29 | fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} 30 | 31 | fn user_event(&mut self, _: &ActiveEventLoop, event: CustomEvent) { 32 | match event { 33 | CustomEvent::Media(event) => { 34 | let type_ = match event { 35 | MediaControlEvent::Play => 0, 36 | MediaControlEvent::Pause => 1, 37 | MediaControlEvent::Toggle => 2, 38 | MediaControlEvent::Next => 3, 39 | MediaControlEvent::Previous => 4, 40 | MediaControlEvent::Stop => 5, 41 | _ => return, 42 | }; 43 | println!("{type_}"); 44 | } 45 | 46 | CustomEvent::Metadata(event) => { 47 | let mut metadata = MediaMetadata::default(); 48 | 49 | for string in event.split('\t') { 50 | let (key, value) = string.split_once(':').unwrap(); 51 | match key { 52 | "title" => metadata.title = Some(value), 53 | "album" => metadata.album = Some(value), 54 | "artist" => metadata.artist = Some(value), 55 | "cover_url" => metadata.cover_url = Some(value), 56 | "duration" => { 57 | metadata.duration = 58 | Some(Duration::from_secs_f64(value.parse().unwrap())) 59 | } 60 | _ => (), 61 | } 62 | } 63 | 64 | self.controls.set_metadata(metadata).unwrap(); 65 | } 66 | 67 | CustomEvent::Playing(event) => { 68 | let (playing, position) = event.split_once(',').unwrap(); 69 | 70 | let progress = Some(MediaPosition(Duration::from_secs_f64( 71 | position.parse().unwrap(), 72 | ))); 73 | self.controls 74 | .set_playback(match playing.parse().unwrap() { 75 | true => MediaPlayback::Playing { progress }, 76 | false => MediaPlayback::Paused { progress }, 77 | }) 78 | .unwrap(); 79 | } 80 | } 81 | } 82 | } 83 | 84 | const TITLE: &str = "Cloudmusic VSCode"; 85 | 86 | fn main() { 87 | let mut event_loop = EventLoop::::with_user_event(); 88 | 89 | #[cfg(target_os = "macos")] 90 | use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; 91 | #[cfg(target_os = "macos")] 92 | event_loop.with_activation_policy(ActivationPolicy::Prohibited); 93 | 94 | let event_loop = event_loop.build().unwrap(); 95 | let mut controls = MediaControls::new(PlatformConfig { 96 | dbus_name: "cloudmusic-vscode", 97 | display_name: TITLE, 98 | hwnd: None, 99 | }) 100 | .unwrap(); 101 | 102 | let event_loop_proxy = event_loop.create_proxy(); 103 | controls 104 | .attach(move |event: MediaControlEvent| { 105 | event_loop_proxy 106 | .send_event(CustomEvent::Media(event)) 107 | .unwrap(); 108 | }) 109 | .unwrap(); 110 | 111 | controls.set_playback(MediaPlayback::Stopped).unwrap(); 112 | 113 | let event_loop_proxy = event_loop.create_proxy(); 114 | thread::spawn(move || loop { 115 | let mut buffer = String::new(); 116 | if stdin().read_line(&mut buffer).unwrap() != 0 { 117 | buffer.remove(buffer.len() - 1); 118 | let type_ = buffer.remove(buffer.len() - 1); 119 | match type_ { 120 | '0' => event_loop_proxy 121 | .send_event(CustomEvent::Metadata(buffer)) 122 | .unwrap(), 123 | '1' => event_loop_proxy 124 | .send_event(CustomEvent::Playing(buffer)) 125 | .unwrap(), 126 | _ => (), 127 | } 128 | } else { 129 | break; 130 | } 131 | }); 132 | 133 | event_loop.set_control_flow(ControlFlow::Wait); 134 | let mut app = App { controls }; 135 | event_loop.run_app(&mut app).unwrap(); 136 | } 137 | -------------------------------------------------------------------------------- /crates/native/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cloudmusic-native" 3 | version = "0.1.0" 4 | authors = ["YXL "] 5 | build = "build.rs" 6 | edition = "2021" 7 | 8 | [lib] 9 | name = "native" 10 | crate-type = ["cdylib"] 11 | 12 | [dependencies.neon] 13 | git = "https://github.com/neon-bindings/neon" 14 | branch = "main" 15 | default-features = false 16 | features = ["napi-7"] 17 | 18 | [dependencies.rodio] 19 | git = "https://github.com/RustAudio/rodio" 20 | branch = "master" 21 | default-features = false 22 | features = ["symphonia-flac", "symphonia-mp3", "symphonia-wav"] 23 | 24 | [target.'cfg(not(target_os = "macos"))'.dependencies] 25 | souvlaki = "0.7.3" 26 | 27 | [target.'cfg(all(target_os = "windows", any(target_arch = "x86_64", target_arch = "x86")))'.dependencies] 28 | raw-window-handle = "0.6.2" 29 | winit = "0.30.0" 30 | 31 | [target.'cfg(target_os = "windows")'.dependencies.windows] 32 | version = "0.56.0" 33 | features = ["Win32_Foundation", "Win32_System_Threading"] 34 | 35 | # [dependencies.curl] 36 | # version = "0.4" 37 | # default-features = false 38 | # features = ["static-curl"] 39 | 40 | # [target.'cfg(target_os = "linux")'.dependencies.x11] 41 | # version = "2.18" 42 | # default-features = false 43 | # features = ["xlib"] 44 | 45 | # [target.'cfg(target_os = "windows")'.dependencies.winapi] 46 | # version = "0.3" 47 | # default-features = false 48 | # features = ["winuser"] 49 | -------------------------------------------------------------------------------- /crates/native/build.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env::var_os, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | #[inline] 7 | fn is_env(env_var: &str, value: &str) -> bool { 8 | var_os(env_var).map(|v| v == value).unwrap_or(false) 9 | } 10 | 11 | #[inline] 12 | fn env_to_path(env_var: &str) -> PathBuf { 13 | PathBuf::from(var_os(env_var).unwrap()) 14 | } 15 | 16 | fn main() { 17 | let output_file = env_to_path("CARGO_MANIFEST_DIR").join("index.node"); 18 | let is_windows = is_env("CARGO_CFG_TARGET_OS", "windows"); 19 | let is_gnu = is_env("CARGO_CFG_TARGET_ENV", "gnu"); 20 | 21 | if is_windows && !is_gnu { 22 | let pdb_file = env_to_path("OUT_DIR") 23 | .join(Path::new(output_file.file_name().unwrap()).with_extension("pdb")); 24 | 25 | println!("cargo:rustc-cdylib-link-arg=/OUT:{}", output_file.display()); 26 | println!("cargo:rustc-cdylib-link-arg=/PDB:{}", pdb_file.display()); 27 | } else { 28 | println!("cargo:rustc-cdylib-link-arg=-o"); 29 | println!("cargo:rustc-cdylib-link-arg={}", output_file.display()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /crates/native/src/keyboard.rs: -------------------------------------------------------------------------------- 1 | use neon::prelude::*; 2 | use std::{sync::Arc, thread, time::Duration}; 3 | 4 | static SLEEP_DURATION: Duration = Duration::from_millis(16); 5 | 6 | #[cfg(target_os = "linux")] 7 | use std::{os::raw::c_char, ptr, slice::from_raw_parts}; 8 | #[cfg(target_os = "linux")] 9 | use x11::xlib::{XOpenDisplay, XQueryKeymap}; 10 | 11 | #[cfg(target_os = "linux")] 12 | static KEYS: [i32; 3] = [5, 4, 3]; 13 | 14 | #[cfg(target_os = "linux")] 15 | pub fn start_keyboard_event(mut cx: FunctionContext) -> JsResult { 16 | let callback = Arc::new(cx.argument::(0)?.root(&mut cx)); 17 | let queue = cx.queue(); 18 | 19 | thread::spawn(move || { 20 | let mut prev = 0; 21 | let keymap: *mut c_char = [0; 32].as_mut_ptr(); 22 | 23 | let disp = unsafe { XOpenDisplay(ptr::null()) }; 24 | 25 | loop { 26 | thread::sleep(SLEEP_DURATION); 27 | let mut flag = false; 28 | unsafe { XQueryKeymap(disp, keymap) }; 29 | let b = unsafe { from_raw_parts(keymap, 32) }[21]; 30 | 31 | for (i, &key) in KEYS.iter().enumerate() { 32 | if b & 1 << key != 0 { 33 | flag = true; 34 | if prev != key { 35 | prev = key; 36 | let callback = callback.clone(); 37 | queue.send(move |mut cx| { 38 | let callback = callback.to_inner(&mut cx); 39 | let this = cx.undefined(); 40 | let args = vec![cx.number(i as f64)]; 41 | 42 | callback.call(&mut cx, this, args)?; 43 | Ok(()) 44 | }); 45 | } 46 | break; 47 | } 48 | } 49 | if !flag { 50 | prev = 0; 51 | } 52 | } 53 | }); 54 | 55 | Ok(cx.undefined()) 56 | } 57 | 58 | #[cfg(target_os = "windows")] 59 | use winapi::um::winuser::{ 60 | GetAsyncKeyState, VK_MEDIA_NEXT_TRACK, VK_MEDIA_PLAY_PAUSE, VK_MEDIA_PREV_TRACK, 61 | }; 62 | 63 | #[cfg(target_os = "windows")] 64 | static KEYS: [i32; 3] = [ 65 | VK_MEDIA_PREV_TRACK, 66 | VK_MEDIA_PLAY_PAUSE, 67 | VK_MEDIA_NEXT_TRACK, 68 | ]; 69 | 70 | #[cfg(target_os = "windows")] 71 | pub fn start_keyboard_event(mut cx: FunctionContext) -> JsResult { 72 | let callback = Arc::new(cx.argument::(0)?.root(&mut cx)); 73 | let queue = cx.queue(); 74 | 75 | thread::spawn(move || { 76 | let mut prev = 0; 77 | 78 | loop { 79 | thread::sleep(SLEEP_DURATION); 80 | let mut flag = false; 81 | 82 | for (i, &key) in KEYS.iter().enumerate() { 83 | if unsafe { GetAsyncKeyState(key) } as u32 & 0x8000 != 0 { 84 | flag = true; 85 | if prev != key { 86 | prev = key; 87 | let callback = callback.clone(); 88 | queue.send(move |mut cx| { 89 | let callback = callback.to_inner(&mut cx); 90 | let this = cx.undefined(); 91 | let args = vec![cx.number(i as f64)]; 92 | 93 | callback.call(&mut cx, this, args)?; 94 | Ok(()) 95 | }); 96 | } 97 | break; 98 | } 99 | } 100 | if !flag { 101 | prev = 0; 102 | } 103 | } 104 | }); 105 | 106 | Ok(cx.undefined()) 107 | } 108 | 109 | #[cfg(target_os = "macos")] 110 | #[link(name = "AppKit", kind = "framework")] 111 | extern "C" { 112 | fn CGEventSourceKeyState(state: i32, keycode: u16) -> bool; 113 | } 114 | 115 | #[cfg(target_os = "macos")] 116 | static KEYS: [u16; 3] = [98, 100, 101]; 117 | 118 | #[cfg(target_os = "macos")] 119 | pub fn start_keyboard_event(mut cx: FunctionContext) -> JsResult { 120 | let callback = Arc::new(cx.argument::(0)?.root(&mut cx)); 121 | let queue = cx.queue(); 122 | 123 | thread::spawn(move || { 124 | let mut prev = 0; 125 | 126 | loop { 127 | thread::sleep(SLEEP_DURATION); 128 | let mut flag = false; 129 | 130 | for (i, &key) in KEYS.iter().enumerate() { 131 | if unsafe { CGEventSourceKeyState(0, key) } { 132 | flag = true; 133 | if prev != key { 134 | prev = key; 135 | let callback = callback.clone(); 136 | queue.send(move |mut cx| { 137 | let callback = callback.to_inner(&mut cx); 138 | let this = cx.undefined(); 139 | let args = vec![cx.number(i as f64)]; 140 | 141 | callback.call(&mut cx, this, args)?; 142 | Ok(()) 143 | }); 144 | } 145 | break; 146 | } 147 | } 148 | if !flag { 149 | prev = 0; 150 | } 151 | } 152 | }); 153 | 154 | Ok(cx.undefined()) 155 | } 156 | -------------------------------------------------------------------------------- /crates/native/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod media; 2 | pub mod player; 3 | 4 | // use crate::download::*; 5 | // use crate::keyboard::*; 6 | use {media::*, neon::prelude::*, player::*}; 7 | 8 | #[neon::main] 9 | fn main(mut cx: ModuleContext) -> NeonResult<()> { 10 | // cx.export_function("download", download)?; 11 | 12 | // cx.export_function("startKeyboardEvent", start_keyboard_event)?; 13 | 14 | cx.export_function("playerEmpty", player_empty)?; 15 | cx.export_function("playerLoad", player_load)?; 16 | cx.export_function("playerNew", player_new)?; 17 | cx.export_function("playerPause", player_pause)?; 18 | cx.export_function("playerPlay", player_play)?; 19 | cx.export_function("playerPosition", player_position)?; 20 | cx.export_function("playerSetSpeed", player_set_speed)?; 21 | cx.export_function("playerSetVolume", player_set_volume)?; 22 | cx.export_function("playerStop", player_stop)?; 23 | cx.export_function("playerSeek", player_seek)?; 24 | 25 | // #[cfg(target_os = "windows")] 26 | // cx.export_function("mediaSessionHwnd", media_session_hwnd)?; 27 | cx.export_function("mediaSessionNew", media_session_new)?; 28 | cx.export_function("mediaSessionSetMetadata", media_session_set_metadata)?; 29 | cx.export_function("mediaSessionSetPlayback", media_session_set_playback)?; 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /crates/wasi/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cloudmusic-wasi" 3 | version = "0.1.0" 4 | authors = ["YXL "] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ['cdylib'] 9 | 10 | [dependencies.wasm-bindgen] 11 | version = "0.2.92" 12 | -------------------------------------------------------------------------------- /crates/wasi/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod kuwo_des; 2 | 3 | use crate::kuwo_des::*; 4 | use wasm_bindgen::prelude::*; 5 | 6 | #[wasm_bindgen] 7 | pub fn kuwo_crypt(msg: &str) -> Vec { 8 | crypt(msg) 9 | } 10 | -------------------------------------------------------------------------------- /crates/wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cloudmusic-wasm" 3 | version = "0.1.0" 4 | authors = ["YXL "] 5 | edition = "2021" 6 | 7 | [lib] 8 | crate-type = ['cdylib'] 9 | 10 | # [features] 11 | # debug = ["console_error_panic_hook"] 12 | 13 | # [dependencies] 14 | # console_error_panic_hook = { version = "0.1", optional = true } 15 | 16 | [dependencies.rodio] 17 | git = "https://github.com/RustAudio/rodio" 18 | branch = "master" 19 | default-features = false 20 | features = ["symphonia-flac", "symphonia-mp3", "symphonia-wav"] 21 | 22 | [dependencies.wasm-bindgen] 23 | version = "0.2.92" 24 | 25 | [dependencies.web-sys] 26 | version = "0.3.69" 27 | features = ["console", "Performance"] 28 | -------------------------------------------------------------------------------- /crates/wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | use { 2 | rodio::{Decoder, OutputStream, OutputStreamHandle, Sink}, 3 | std::{io::Cursor, time::Duration}, 4 | wasm_bindgen::prelude::*, 5 | web_sys::{console, window}, 6 | }; 7 | 8 | #[wasm_bindgen(start)] 9 | pub fn main_js() -> Result<(), JsValue> { 10 | // This provides better error messages in debug mode. 11 | // It's disabled in release mode so it doesn't bloat up the file size. 12 | // #[cfg(feature = "debug")] 13 | // console_error_panic_hook::set_once(); 14 | 15 | Ok(()) 16 | } 17 | 18 | enum Status { 19 | Playing(f64, f64), 20 | Stopped(f64), 21 | } 22 | 23 | #[inline] 24 | fn now() -> f64 { 25 | window().unwrap().performance().unwrap().now() / 1000.0 26 | } 27 | 28 | #[inline] 29 | fn elapsed(old: f64) -> f64 { 30 | now() - old 31 | } 32 | 33 | impl Status { 34 | #[inline] 35 | fn new() -> Status { 36 | Status::Stopped(0.0) 37 | } 38 | 39 | #[inline] 40 | fn elapsed(&self, speed: f64) -> f64 { 41 | match *self { 42 | Status::Stopped(d) => d, 43 | Status::Playing(start, extra) => elapsed(start) * speed + extra, 44 | } 45 | } 46 | 47 | #[inline] 48 | fn stop(&mut self, speed: f64) { 49 | if let Status::Playing(start, extra) = *self { 50 | *self = Status::Stopped(elapsed(start) * speed + extra) 51 | } 52 | } 53 | 54 | #[inline] 55 | fn play(&mut self) { 56 | if let Status::Stopped(duration) = *self { 57 | *self = Status::Playing(now(), duration) 58 | } 59 | } 60 | 61 | #[inline] 62 | fn reset(&mut self) { 63 | *self = Status::Stopped(0.0); 64 | } 65 | 66 | #[inline] 67 | fn store(&mut self, speed: f64) { 68 | if let Status::Playing(start, extra) = *self { 69 | *self = Status::Playing(now(), elapsed(start) * speed + extra) 70 | } 71 | } 72 | 73 | #[inline] 74 | fn seek(&mut self, pos: f64) { 75 | match self { 76 | Status::Stopped(d) => *d = pos, 77 | Status::Playing(start, extra) => { 78 | *start = now(); 79 | *extra = pos; 80 | } 81 | } 82 | } 83 | } 84 | 85 | #[wasm_bindgen] 86 | pub struct Player { 87 | speed: f64, 88 | volume: f32, 89 | status: Status, 90 | sink: Option, 91 | #[allow(dead_code)] 92 | stream: OutputStream, 93 | handle: OutputStreamHandle, 94 | } 95 | 96 | impl Default for Player { 97 | fn default() -> Self { 98 | Self::new() 99 | } 100 | } 101 | 102 | #[wasm_bindgen] 103 | impl Player { 104 | #[wasm_bindgen(constructor)] 105 | pub fn new() -> Self { 106 | let (stream, handle) = OutputStream::try_default().unwrap(); 107 | console::log_1(&"Audio Player: WASM".into()); 108 | Self { 109 | speed: 1., 110 | volume: 0., 111 | status: Status::new(), 112 | sink: None, 113 | stream, 114 | handle, 115 | } 116 | } 117 | 118 | #[wasm_bindgen] 119 | pub fn load(&mut self, data: &[u8], play: bool, _seek: Option) -> bool { 120 | self.stop(); 121 | 122 | if let Ok(sink) = Sink::try_new(&self.handle) { 123 | sink.set_speed(self.speed as f32); 124 | sink.set_volume(self.volume); 125 | let cur = Cursor::new(data.to_owned()); 126 | let decoder = Decoder::new(cur).unwrap(); 127 | sink.append(decoder); 128 | if play { 129 | self.status.play(); 130 | } else { 131 | sink.pause() 132 | } 133 | self.sink = Some(sink); 134 | return true; 135 | } 136 | 137 | false 138 | } 139 | 140 | #[wasm_bindgen] 141 | pub fn play(&mut self) -> bool { 142 | if let Some(ref sink) = self.sink { 143 | if sink.empty() { 144 | return false; 145 | } else { 146 | sink.play(); 147 | self.status.play() 148 | } 149 | } 150 | true 151 | } 152 | 153 | #[wasm_bindgen] 154 | pub fn pause(&mut self) { 155 | if let Some(ref sink) = self.sink { 156 | sink.pause(); 157 | self.status.stop(self.speed); 158 | } 159 | } 160 | 161 | #[wasm_bindgen] 162 | pub fn stop(&mut self) { 163 | self.sink = None; 164 | self.status.reset() 165 | } 166 | 167 | #[wasm_bindgen] 168 | pub fn set_speed(&mut self, speed: f64) { 169 | if let Some(ref sink) = self.sink { 170 | sink.set_speed(speed as f32); 171 | self.status.store(self.speed); 172 | } 173 | self.speed = speed; 174 | } 175 | 176 | #[wasm_bindgen] 177 | pub fn set_volume(&mut self, level: f32) { 178 | if let Some(ref sink) = self.sink { 179 | sink.set_volume(level); 180 | } 181 | self.volume = level; 182 | } 183 | 184 | #[wasm_bindgen] 185 | pub fn empty(&self) -> bool { 186 | if let Some(ref sink) = self.sink { 187 | sink.empty() 188 | } else { 189 | true 190 | } 191 | } 192 | 193 | #[wasm_bindgen] 194 | pub fn position(&self) -> f64 { 195 | self.status.elapsed(self.speed) 196 | } 197 | 198 | #[wasm_bindgen] 199 | pub fn seek(&mut self, offset: f64) { 200 | if let Some(ref sink) = self.sink { 201 | if let Ok(pos) = Duration::try_from_secs_f64(self.position() + offset) { 202 | if let Ok(_) = sink.try_seek(pos) { 203 | self.status.seek(pos.as_secs_f64()); 204 | } 205 | } 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "strict": true 5 | }, 6 | "fmt": { 7 | "files": { 8 | "include": ["scripts/"] 9 | }, 10 | "options": { 11 | "lineWidth": 120 12 | } 13 | }, 14 | "lint": { 15 | "files": { 16 | "include": ["scripts/"] 17 | }, 18 | "rules": { 19 | "include": ["camelcase", "eqeqeq", "explicit-module-boundary-types"], 20 | "tags": ["recommended"] 21 | } 22 | }, 23 | "tasks": {} 24 | } 25 | -------------------------------------------------------------------------------- /doc/usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/doc/usage.png -------------------------------------------------------------------------------- /docker/Dockerfile.aarch64-unknown-linux-gnu: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:latest 2 | 3 | ENV PKG_CONFIG_ALLOW_CROSS 1 4 | ENV PKG_CONFIG_PATH /usr/lib/aarch64-linux-gnu/pkgconfig/ 5 | 6 | RUN dpkg --add-architecture arm64 && \ 7 | apt-get update && \ 8 | apt-get install -y libasound2-dev:arm64 libdbus-1-dev:arm64 \ -------------------------------------------------------------------------------- /docker/Dockerfile.armv7-unknown-linux-gnueabihf: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:latest 2 | 3 | ENV PKG_CONFIG_ALLOW_CROSS 1 4 | ENV PKG_CONFIG_PATH /usr/lib/arm-linux-gnueabihf/pkgconfig/ 5 | 6 | RUN dpkg --add-architecture armhf && \ 7 | apt-get update && \ 8 | apt-get install -y libasound2-dev:armhf libdbus-1-dev:armhf \ -------------------------------------------------------------------------------- /media/audio/silent.flac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/audio/silent.flac -------------------------------------------------------------------------------- /media/audio/silent.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/audio/silent.mp3 -------------------------------------------------------------------------------- /media/disc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/icon-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/icon-150.png -------------------------------------------------------------------------------- /media/icon-300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/icon-300.png -------------------------------------------------------------------------------- /media/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/icon-72.png -------------------------------------------------------------------------------- /media/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/icon.ico -------------------------------------------------------------------------------- /media/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 10 | 15 | 18 | 19 | 21 | 23 | 33 | 34 | -------------------------------------------------------------------------------- /media/walkthroughs/queue.md: -------------------------------------------------------------------------------- 1 | # Queue 2 | 3 | ## Initialization 4 | 5 | You can specify the initialization behavior setting `cloudmusic.queue.initialization`. 6 | -------------------------------------------------------------------------------- /media/walkthroughs/statusBar-compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/walkthroughs/statusBar-compact.png -------------------------------------------------------------------------------- /media/walkthroughs/statusBar-normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YXL76/cloudmusic-vscode/da5bfa2638e18b58ce3300c96ed33e7bfd0aa9fe/media/walkthroughs/statusBar-normal.png -------------------------------------------------------------------------------- /media/walkthroughs/statusBar.md: -------------------------------------------------------------------------------- 1 | # Status Bar 2 | 3 | ## Style 4 | 5 | You can change the style of the status bar. 6 | 7 | ### Normal 8 | 9 | ![status bar normal](./statusBar-normal.png) 10 | 11 | [Apply](command:cloudmusic.statusBarStyle?[false]) 12 | 13 | ## Show/Hide 14 | 15 | You can show or hide the status bar item by running [cloudmusic.toggleButton](command:cloudmusic.toggleButton). 16 | -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "commands.account.title": "Account", 3 | "commands.addAccount.title": "Add", 4 | "commands.addLocalFile.title": "Add", 5 | "commands.addLocalLibrary.title": "Add", 6 | "commands.addPlaylist.title": "Add", 7 | "commands.addProgram.title": "Add", 8 | "commands.addRadio.title": "Add", 9 | "commands.addSong.title": "Add", 10 | "commands.clearQueue.title": "Clear", 11 | "commands.copyPlaylistLink.title": "Copy link", 12 | "commands.copyProgramLink.title": "Copy link", 13 | "commands.copyRadioLink.title": "Copy link", 14 | "commands.copySongLink.title": "Copy link", 15 | "commands.createPlaylist.title": "Create playlist", 16 | "commands.dailyCheck.title": "Daily check", 17 | "commands.deleteFromPlaylist.title": "Delete song", 18 | "commands.deleteLocalLibrary.title": "Delete", 19 | "commands.deletePlaylist.title": "Delete playlist", 20 | "commands.deleteSong.title": "Delete", 21 | "commands.downloadSong.title": "Download", 22 | "commands.editPlaylist.title": "Edit playlist", 23 | "commands.fmTrash.title": "Fm trash", 24 | "commands.intelligence.title": "Intelligence mode", 25 | "commands.like.title": "Like", 26 | "commands.lyric.title": "Lyric", 27 | "commands.newLocalLibrary.title": "Add", 28 | "commands.next.title": "Next", 29 | "commands.openLocalLibrary.title": "Open", 30 | "commands.openLogFile.title": "Open log File", 31 | "commands.playlistComment.title": "Comment", 32 | "commands.playlistDetail.title": "Detail", 33 | "commands.playLocalFile.title": "Play", 34 | "commands.playLocalLibrary.title": "Play", 35 | "commands.playNext.title": "Play next", 36 | "commands.playPlaylist.title": "Play", 37 | "commands.playRadio.title": "Play", 38 | "commands.playSong.title": "Play", 39 | "commands.previous.title": "Previous", 40 | "commands.programComment.title": "Comment", 41 | "commands.radioDetail.title": "Detail", 42 | "commands.randomQueue.title": "Shuffle", 43 | "commands.refreshLocalFile.title": "Refresh", 44 | "commands.refreshLocalLibrary.title": "Refresh", 45 | "commands.refreshPlaylist.title": "Refresh", 46 | "commands.refreshPlaylistContent.title": "Refresh", 47 | "commands.refreshRadio.title": "Refresh", 48 | "commands.refreshRadioContent.title": "Refresh", 49 | "commands.repeat.title": "Repeat", 50 | "commands.saveToPlaylist.title": "Save to playlist", 51 | "commands.seekbackward.title": "Seek backward", 52 | "commands.seekforward.title": "Seek forward", 53 | "commands.songComment.title": "Comment", 54 | "commands.songDetail.title": "Detail", 55 | "commands.sortQueue.title": "Sort", 56 | "commands.speed.title": "Speed", 57 | "commands.toggle.title": "Play", 58 | "commands.toggleButton.title": "Toggle button", 59 | "commands.unsubRadio.title": "Unsubscribe", 60 | "commands.volume.title": "Volume", 61 | "configuration.account.autoCheck.markdownDescription": "Auto check when extension is active", 62 | "configuration.cache.path.markdownDescription": "Default value is `~/.cloudmusic`", 63 | "configuration.cache.size.markdownDescription": "Set maximum cache size (`128 ~ 10240 MB`)", 64 | "configuration.host.autoStart.markdownDescription": "Automatically activate the extension", 65 | "configuration.music.quality.enumDescriptions.high": "High Quality", 66 | "configuration.music.quality.enumDescriptions.lossless": "Lossless Quality", 67 | "configuration.music.quality.enumDescriptions.low": "Low Quality", 68 | "configuration.music.quality.enumDescriptions.medium": "Medium Quality", 69 | "configuration.music.quality.markdownDescription": "Choose your prefer music quality", 70 | "configuration.network.strictSSL.markdownDescription": "SSL certificate verification", 71 | "views.cloudmusic-account.name": "Account", 72 | "views.cloudmusic-local.name": "Local library", 73 | "views.cloudmusic-playlist.name": "Playlist", 74 | "views.cloudmusic-queue.name": "Queue", 75 | "views.cloudmusic-radio.name": "Radio", 76 | "viewsContainers.activitybar.title": "Cloudmusic" 77 | } -------------------------------------------------------------------------------- /package.nls.zh-cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "commands.account.title": "帐号", 3 | "commands.addAccount.title": "添加", 4 | "commands.addLocalFile.title": "添加", 5 | "commands.addLocalLibrary.title": "添加", 6 | "commands.addPlaylist.title": "添加", 7 | "commands.addProgram.title": "添加", 8 | "commands.addRadio.title": "添加", 9 | "commands.addSong.title": "添加", 10 | "commands.clearQueue.title": "清空", 11 | "commands.copyPlaylistLink.title": "复制链接", 12 | "commands.copyProgramLink.title": "复制链接", 13 | "commands.copyRadioLink.title": "复制链接", 14 | "commands.copySongLink.title": "复制链接", 15 | "commands.createPlaylist.title": "新建歌单", 16 | "commands.dailyCheck.title": "签到", 17 | "commands.deleteFromPlaylist.title": "删除单曲", 18 | "commands.deleteLocalLibrary.title": "删除", 19 | "commands.deletePlaylist.title": "删除歌单", 20 | "commands.deleteSong.title": "删除", 21 | "commands.downloadSong.title": "下载", 22 | "commands.editPlaylist.title": "编辑歌单", 23 | "commands.fmTrash.title": "垃圾箱", 24 | "commands.intelligence.title": "心动模式", 25 | "commands.like.title": "喜欢", 26 | "commands.lyric.title": "歌词", 27 | "commands.newLocalLibrary.title": "添加", 28 | "commands.next.title": "下一首", 29 | "commands.openLocalLibrary.title": "打开", 30 | "commands.openLogFile.title": "打开日志文件", 31 | "commands.playlistComment.title": "评论", 32 | "commands.playlistDetail.title": "详情", 33 | "commands.playLocalFile.title": "播放", 34 | "commands.playLocalLibrary.title": "播放", 35 | "commands.playNext.title": "下一首播放", 36 | "commands.playPlaylist.title": "播放", 37 | "commands.playRadio.title": "播放", 38 | "commands.playSong.title": "播放", 39 | "commands.previous.title": "前一首", 40 | "commands.programComment.title": "评论", 41 | "commands.radioDetail.title": "详情", 42 | "commands.randomQueue.title": "随机播放", 43 | "commands.refreshLocalFile.title": "刷新", 44 | "commands.refreshLocalLibrary.title": "刷新", 45 | "commands.refreshPlaylist.title": "刷新", 46 | "commands.refreshPlaylistContent.title": "刷新", 47 | "commands.refreshRadio.title": "刷新", 48 | "commands.refreshRadioContent.title": "刷新", 49 | "commands.repeat.title": "单曲循环", 50 | "commands.saveToPlaylist.title": "添加到歌单", 51 | "commands.seekbackward.title": "向后搜索", 52 | "commands.seekforward.title": "向前搜索", 53 | "commands.songComment.title": "评论", 54 | "commands.songDetail.title": "详情", 55 | "commands.sortQueue.title": "排序", 56 | "commands.speed.title": "播放速度", 57 | "commands.toggle.title": "播放", 58 | "commands.toggleButton.title": "按钮切换", 59 | "commands.unsubRadio.title": "取消收藏", 60 | "commands.volume.title": "音量", 61 | "configuration.account.autoCheck.markdownDescription": "自动签到", 62 | "configuration.cache.path.markdownDescription": "默认值为 `~/.cloudmusic`", 63 | "configuration.cache.size.markdownDescription": "设置缓存限制 (`128 ~ 10240 MB`)", 64 | "configuration.host.autoStart.markdownDescription": "自动激活拓展", 65 | "configuration.music.quality.enumDescriptions.high": "极高", 66 | "configuration.music.quality.enumDescriptions.lossless": "无损", 67 | "configuration.music.quality.enumDescriptions.low": "普通", 68 | "configuration.music.quality.enumDescriptions.medium": "较高", 69 | "configuration.music.quality.markdownDescription": "选择优先播放的音质", 70 | "configuration.network.strictSSL.markdownDescription": "SSL 证书检查", 71 | "views.cloudmusic-account.name": "帐号", 72 | "views.cloudmusic-local.name": "本地曲库", 73 | "views.cloudmusic-playlist.name": "歌单", 74 | "views.cloudmusic-queue.name": "队列", 75 | "views.cloudmusic-radio.name": "播单", 76 | "viewsContainers.activitybar.title": "云音乐" 77 | } -------------------------------------------------------------------------------- /package.nls.zh-tw.json: -------------------------------------------------------------------------------- 1 | { 2 | "commands.account.title": "帳號", 3 | "commands.addAccount.title": "添加", 4 | "commands.addLocalFile.title": "添加", 5 | "commands.addLocalLibrary.title": "添加", 6 | "commands.addPlaylist.title": "添加", 7 | "commands.addProgram.title": "添加", 8 | "commands.addRadio.title": "添加", 9 | "commands.addSong.title": "添加", 10 | "commands.clearQueue.title": "清空", 11 | "commands.copyPlaylistLink.title": "複製連結", 12 | "commands.copyProgramLink.title": "複製連結", 13 | "commands.copyRadioLink.title": "複製連結", 14 | "commands.copySongLink.title": "複製連結", 15 | "commands.createPlaylist.title": "新建歌單", 16 | "commands.dailyCheck.title": "簽到", 17 | "commands.deleteFromPlaylist.title": "删除單曲", 18 | "commands.deleteLocalLibrary.title": "删除", 19 | "commands.deletePlaylist.title": "删除歌單", 20 | "commands.deleteSong.title": "删除", 21 | "commands.downloadSong.title": "下載", 22 | "commands.editPlaylist.title": "編輯歌單", 23 | "commands.fmTrash.title": "垃圾箱", 24 | "commands.intelligence.title": "心動模式", 25 | "commands.like.title": "喜歡", 26 | "commands.lyric.title": "歌詞", 27 | "commands.newLocalLibrary.title": "添加", 28 | "commands.next.title": "下一首", 29 | "commands.openLocalLibrary.title": "打開", 30 | "commands.openLogFile.title": "打開日誌檔", 31 | "commands.playlistComment.title": "評論", 32 | "commands.playlistDetail.title": "詳情", 33 | "commands.playLocalFile.title": "播放", 34 | "commands.playLocalLibrary.title": "播放", 35 | "commands.playNext.title": "下一首播放", 36 | "commands.playPlaylist.title": "播放", 37 | "commands.playRadio.title": "播放", 38 | "commands.playSong.title": "播放", 39 | "commands.previous.title": "前一首", 40 | "commands.programComment.title": "評論", 41 | "commands.radioDetail.title": "詳情", 42 | "commands.randomQueue.title": "隨機播放", 43 | "commands.refreshLocalFile.title": "重繪", 44 | "commands.refreshLocalLibrary.title": "重繪", 45 | "commands.refreshPlaylist.title": "重繪", 46 | "commands.refreshPlaylistContent.title": "重繪", 47 | "commands.refreshRadio.title": "重繪", 48 | "commands.refreshRadioContent.title": "重繪", 49 | "commands.repeat.title": "單曲循環", 50 | "commands.saveToPlaylist.title": "添加到歌單", 51 | "commands.seekbackward.title": "向後搜索", 52 | "commands.seekforward.title": "向前搜索", 53 | "commands.songComment.title": "評論", 54 | "commands.songDetail.title": "詳情", 55 | "commands.sortQueue.title": "排序", 56 | "commands.speed.title": "播放速度", 57 | "commands.toggle.title": "播放", 58 | "commands.toggleButton.title": "按鈕切換", 59 | "commands.unsubRadio.title": "取消收藏", 60 | "commands.volume.title": "音量", 61 | "configuration.account.autoCheck.markdownDescription": "自動簽到", 62 | "configuration.cache.path.markdownDescription": "默認值爲 `~/.cloudmusic`", 63 | "configuration.cache.size.markdownDescription": "設定緩存限制 (`128 ~ 10240 MB`)", 64 | "configuration.host.autoStart.markdownDescription": "自動激活拓展", 65 | "configuration.music.quality.enumDescriptions.high": "極高", 66 | "configuration.music.quality.enumDescriptions.lossless": "無損", 67 | "configuration.music.quality.enumDescriptions.low": "普通", 68 | "configuration.music.quality.enumDescriptions.medium": "較高", 69 | "configuration.music.quality.markdownDescription": "選擇優先播放的音質", 70 | "configuration.network.strictSSL.markdownDescription": "SSL 證書檢查", 71 | "views.cloudmusic-account.name": "帳號", 72 | "views.cloudmusic-local.name": "本地曲庫", 73 | "views.cloudmusic-playlist.name": "歌單", 74 | "views.cloudmusic-queue.name": "隊列", 75 | "views.cloudmusic-radio.name": "播單", 76 | "viewsContainers.activitybar.title": "雲音樂" 77 | } -------------------------------------------------------------------------------- /packages/@types/api/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./netease.js"; 2 | -------------------------------------------------------------------------------- /packages/@types/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@types/api", 3 | "version": "1.0.0", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /packages/@types/qrcode/index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for qrcode 1.5 2 | // Project: https://github.com/soldair/node-qrcode 3 | // Definitions by: York Yao 4 | // Michael Nahkies 5 | // Rémi Sormain 6 | // BendingBender 7 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 8 | 9 | /// 10 | 11 | export type QRCodeErrorCorrectionLevel = "low" | "medium" | "quartile" | "high" | "L" | "M" | "Q" | "H"; 12 | export type QRCodeMaskPattern = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; 13 | export type QRCodeToSJISFunc = (codePoint: string) => number; 14 | 15 | export interface QRCodeOptions { 16 | /** 17 | * QR Code version. If not specified the more suitable value will be calculated. 18 | */ 19 | version?: number | undefined; 20 | /** 21 | * Error correction level. 22 | * @default 'M' 23 | */ 24 | errorCorrectionLevel?: QRCodeErrorCorrectionLevel | undefined; 25 | /** 26 | * Mask pattern used to mask the symbol. 27 | * 28 | * If not specified the more suitable value will be calculated. 29 | */ 30 | maskPattern?: QRCodeMaskPattern | undefined; 31 | /** 32 | * Helper function used internally to convert a kanji to its Shift JIS value. 33 | * Provide this function if you need support for Kanji mode. 34 | */ 35 | toSJISFunc?: QRCodeToSJISFunc | undefined; 36 | } 37 | export interface QRCodeRenderersOptions extends QRCodeOptions { 38 | /** 39 | * Define how much wide the quiet zone should be. 40 | * @default 4 41 | */ 42 | margin?: number | undefined; 43 | /** 44 | * Scale factor. A value of `1` means 1px per modules (black dots). 45 | * @default 4 46 | */ 47 | scale?: number | undefined; 48 | /** 49 | * Forces a specific width for the output image. 50 | * If width is too small to contain the qr symbol, this option will be ignored. 51 | * Takes precedence over `scale`. 52 | */ 53 | width?: number | undefined; 54 | color?: 55 | | { 56 | /** 57 | * Color of dark module. Value must be in hex format (RGBA). 58 | * Note: dark color should always be darker than `color.light`. 59 | * @default '#000000ff' 60 | */ 61 | dark?: string | undefined; 62 | /** 63 | * Color of light module. Value must be in hex format (RGBA). 64 | * @default '#ffffffff' 65 | */ 66 | light?: string | undefined; 67 | } 68 | | undefined; 69 | } 70 | 71 | /** 72 | * Draws qr code symbol to canvas. 73 | */ 74 | export function toCanvas( 75 | canvasElement: HTMLCanvasElement, 76 | text: string, 77 | options: QRCodeRenderersOptions 78 | ): Promise; 79 | -------------------------------------------------------------------------------- /packages/@types/qrcode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@types/qrcode", 3 | "version": "1.4.0", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /packages/@types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "lib": ["es2022", "dom"], 6 | "module": "node16", 7 | "noEmit": true, 8 | "noImplicitAny": true, 9 | "noImplicitThis": true, 10 | "strict": true, 11 | "strictFunctionTypes": true, 12 | "target": "ES2022" 13 | }, 14 | "include": ["."] 15 | } 16 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudmusic/client", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "check": "tsc -p tsconfig.json", 7 | "lint": "eslint src/**" 8 | }, 9 | "type": "module", 10 | "devDependencies": { 11 | "@types/api": "workspace:*", 12 | "@types/lodash": "4.17.4", 13 | "@types/node": "*", 14 | "@types/vscode": "1.74.0" 15 | }, 16 | "dependencies": { 17 | "@cloudmusic/server": "workspace:*", 18 | "@cloudmusic/shared": "workspace:*", 19 | "lodash": "4.17.21", 20 | "music-metadata": "8.2.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/client/src/activate/account.ts: -------------------------------------------------------------------------------- 1 | import { commands, window } from "vscode"; 2 | import { AccountManager } from "../manager/index.js"; 3 | import type { ExtensionContext } from "vscode"; 4 | import { MultiStepInput } from "../utils/index.js"; 5 | import i18n from "../i18n/index.js"; 6 | 7 | export async function initAccount(context: ExtensionContext): Promise { 8 | context.subscriptions.push( 9 | commands.registerCommand("cloudmusic.addAccount", () => AccountManager.loginQuickPick()), 10 | 11 | commands.registerCommand( 12 | "cloudmusic.account", 13 | () => 14 | void MultiStepInput.run(async (input) => { 15 | const pick = await input.showQuickPick({ 16 | title: i18n.word.account, 17 | step: 1, 18 | items: [...AccountManager.accounts].map(([uid, { nickname }]) => ({ 19 | label: `$(account) ${nickname}`, 20 | uid, 21 | })), 22 | }); 23 | AccountManager.accountQuickPick(pick.uid); 24 | }), 25 | ), 26 | 27 | commands.registerCommand( 28 | "cloudmusic.dailyCheck", 29 | async () => 30 | void window.showInformationMessage( 31 | (await AccountManager.dailyCheck()) ? i18n.sentence.success.dailyCheck : i18n.sentence.error.needSignIn, 32 | ), 33 | ), 34 | ); 35 | 36 | await AccountManager.init(); 37 | } 38 | -------------------------------------------------------------------------------- /packages/client/src/activate/cache.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_KEY, CONF, LYRIC_CACHE_KEY, MUSIC_QUALITY } from "../constant/index.js"; 2 | import type { ExtensionContext } from "vscode"; 3 | import { IPC } from "../utils/index.js"; 4 | import { workspace } from "vscode"; 5 | 6 | export function initCache(context: ExtensionContext): void { 7 | const updateMQ = () => { 8 | const MQ = `${MUSIC_QUALITY(CONF())}`; 9 | if (context.globalState.get(CACHE_KEY) !== MQ) IPC.cache(); 10 | void context.globalState.update(CACHE_KEY, MQ); 11 | }; 12 | 13 | updateMQ(); 14 | 15 | if (!context.globalState.get(LYRIC_CACHE_KEY)) IPC.lyric(); 16 | void context.globalState.update(LYRIC_CACHE_KEY, LYRIC_CACHE_KEY); 17 | 18 | context.subscriptions.push( 19 | workspace.onDidChangeConfiguration(({ affectsConfiguration }) => { 20 | if (affectsConfiguration("cloudmusic")) { 21 | updateMQ(); 22 | IPC.setting(); 23 | } 24 | }), 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/client/src/activate/command.ts: -------------------------------------------------------------------------------- 1 | import { IPC, MultiStepInput, STATE, likeMusic } from "../utils/index.js"; 2 | import { QueueItemTreeItem, QueueProvider } from "../treeview/index.js"; 3 | import { SPEED_KEY, VOLUME_KEY } from "../constant/index.js"; 4 | import { BUTTON_MANAGER } from "../manager/index.js"; 5 | import type { ExtensionContext } from "vscode"; 6 | import { commands } from "vscode"; 7 | import i18n from "../i18n/index.js"; 8 | 9 | export function initCommand(context: ExtensionContext): void { 10 | context.subscriptions.push( 11 | commands.registerCommand("cloudmusic.seekbackward", IPC.seek.bind(undefined, -15)), 12 | 13 | commands.registerCommand("cloudmusic.seekforward", IPC.seek.bind(undefined, 15)), 14 | 15 | commands.registerCommand("cloudmusic.previous", () => { 16 | if (!STATE.fmUid && QueueProvider.len) IPC.shift(-1); 17 | }), 18 | 19 | commands.registerCommand("cloudmusic.next", async () => { 20 | if (STATE.fmUid) { 21 | const item = await IPC.netease("personalFm", [STATE.fmUid, true]); 22 | if (item) STATE.playItem = QueueItemTreeItem.new({ ...item, pid: item.al.id, itemType: "q" }); 23 | } else if (QueueProvider.len) IPC.shift(1); 24 | }), 25 | 26 | commands.registerCommand("cloudmusic.toggle", IPC.toggle), 27 | 28 | commands.registerCommand("cloudmusic.repeat", () => IPC.repeat(!STATE.repeat)), 29 | 30 | commands.registerCommand("cloudmusic.like", () => { 31 | if (STATE.like && STATE.playItem instanceof QueueItemTreeItem) { 32 | const id = STATE.playItem.valueOf; 33 | void MultiStepInput.run((input) => likeMusic(input, 1, id)); 34 | } 35 | }), 36 | 37 | commands.registerCommand( 38 | "cloudmusic.volume", 39 | () => 40 | void MultiStepInput.run(async (input) => { 41 | const levelS = await input.showInputBox({ 42 | title: i18n.word.volume, 43 | step: 1, 44 | totalSteps: 1, 45 | value: `${context.globalState.get(VOLUME_KEY, 85)}`, 46 | prompt: `${i18n.sentence.hint.volume} (0~100)`, 47 | }); 48 | if (/^[1-9]\d$|^\d$|^100$/.exec(levelS)) { 49 | const level = parseInt(levelS); 50 | IPC.volume(level); 51 | await context.globalState.update(VOLUME_KEY, level); 52 | } 53 | return input.stay(); 54 | }), 55 | ), 56 | 57 | commands.registerCommand( 58 | "cloudmusic.speed", 59 | () => 60 | void MultiStepInput.run(async (input) => { 61 | const speedS = await input.showInputBox({ 62 | title: i18n.word.speed, 63 | step: 1, 64 | totalSteps: 1, 65 | value: `${context.globalState.get(SPEED_KEY, 1)}`, 66 | prompt: i18n.sentence.hint.speed, 67 | }); 68 | const speed = parseFloat(speedS); 69 | if (!isNaN(speed)) { 70 | IPC.speed(speed); 71 | await context.globalState.update(SPEED_KEY, speed); 72 | } 73 | return input.stay(); 74 | }), 75 | ), 76 | 77 | commands.registerCommand("cloudmusic.toggleButton", () => BUTTON_MANAGER.toggle()), 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /packages/client/src/activate/index.ts: -------------------------------------------------------------------------------- 1 | import { AccountViewProvider } from "../utils/index.js"; 2 | import type { ExtensionContext } from "vscode"; 3 | import { initAccount } from "./account.js"; 4 | import { initCache } from "./cache.js"; 5 | import { initCommand } from "./command.js"; 6 | import { initIPC } from "./ipc.js"; 7 | import { initLocal } from "./local.js"; 8 | import { initPlaylist } from "./playlist.js"; 9 | import { initQueue } from "./queue.js"; 10 | import { initRadio } from "./radio.js"; 11 | import { initStatusBar } from "./statusBar.js"; 12 | import { window } from "vscode"; 13 | 14 | export async function realActivate(context: ExtensionContext) { 15 | context.subscriptions.push( 16 | window.registerWebviewViewProvider("cloudmusic-account", new AccountViewProvider(), { 17 | webviewOptions: { retainContextWhenHidden: true }, 18 | }), 19 | ); 20 | initQueue(context); 21 | initCommand(context); 22 | initStatusBar(context); 23 | await initIPC(context); 24 | initLocal(context); 25 | initCache(context); 26 | initPlaylist(context); 27 | initRadio(context); 28 | await initAccount(context); 29 | } 30 | -------------------------------------------------------------------------------- /packages/client/src/activate/local.ts: -------------------------------------------------------------------------------- 1 | import type { LocalFileTreeItem, LocalLibraryTreeItem } from "../treeview/index.js"; 2 | import { Uri, commands, env, window } from "vscode"; 3 | import type { ExtensionContext } from "vscode"; 4 | import { IPC } from "../utils/index.js"; 5 | import { LOCAL_FOLDER_KEY } from "../constant/index.js"; 6 | import { LocalProvider } from "../treeview/index.js"; 7 | 8 | export function initLocal(context: ExtensionContext): void { 9 | context.globalState.get(LOCAL_FOLDER_KEY)?.forEach((f) => void LocalProvider.addFolder(f)); 10 | LocalProvider.refresh(); 11 | 12 | context.subscriptions.push( 13 | commands.registerCommand("cloudmusic.newLocalLibrary", async () => { 14 | const path = ( 15 | await window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false }) 16 | )?.shift()?.fsPath; 17 | if (!path) return; 18 | const folders = await LocalProvider.addFolder(path); 19 | if (folders) await context.globalState.update(LOCAL_FOLDER_KEY, folders); 20 | }), 21 | 22 | commands.registerCommand("cloudmusic.refreshLocalLibrary", () => LocalProvider.refresh()), 23 | 24 | commands.registerCommand("cloudmusic.deleteLocalLibrary", ({ label }: LocalLibraryTreeItem) => { 25 | const folders = LocalProvider.deleteFolder(label); 26 | if (folders !== undefined) void context.globalState.update(LOCAL_FOLDER_KEY, folders); 27 | }), 28 | 29 | commands.registerCommand( 30 | "cloudmusic.openLocalLibrary", 31 | ({ label }: LocalLibraryTreeItem) => void env.openExternal(Uri.file(label)), 32 | ), 33 | 34 | commands.registerCommand("cloudmusic.playLocalLibrary", async (element: LocalLibraryTreeItem) => { 35 | const items = await LocalProvider.refreshLibrary(element); 36 | IPC.new(items); 37 | }), 38 | 39 | commands.registerCommand("cloudmusic.addLocalLibrary", async (element: LocalLibraryTreeItem) => { 40 | const items = await LocalProvider.refreshLibrary(element); 41 | IPC.add(items); 42 | }), 43 | 44 | commands.registerCommand("cloudmusic.refreshLocalFile", (element: LocalLibraryTreeItem) => 45 | LocalProvider.refreshLibrary(element, true), 46 | ), 47 | 48 | commands.registerCommand("cloudmusic.addLocalFile", ({ data }: LocalFileTreeItem) => IPC.add([data])), 49 | 50 | commands.registerCommand("cloudmusic.playLocalFile", ({ data }: LocalFileTreeItem) => IPC.new([data])), 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /packages/client/src/activate/queue.ts: -------------------------------------------------------------------------------- 1 | import { IPC, MultiStepInput } from "../utils/index.js"; 2 | import { QueueProvider, QueueSortOrder, QueueSortType } from "../treeview/index.js"; 3 | import type { ExtensionContext } from "vscode"; 4 | import type { QueueContent } from "../treeview/index.js"; 5 | import { commands } from "vscode"; 6 | import i18n from "../i18n/index.js"; 7 | 8 | export function initQueue(context: ExtensionContext): void { 9 | context.subscriptions.push( 10 | commands.registerCommand("cloudmusic.sortQueue", () => { 11 | void MultiStepInput.run(async (input) => { 12 | const pick = await input.showQuickPick({ 13 | title: i18n.word.account, 14 | step: 1, 15 | totalSteps: 1, 16 | items: [ 17 | { 18 | label: `$(zap) ${i18n.word.song}`, 19 | description: i18n.word.ascending, 20 | type: QueueSortType.song, 21 | order: QueueSortOrder.ascending, 22 | }, 23 | { 24 | label: `$(zap) ${i18n.word.song}`, 25 | description: i18n.word.descending, 26 | type: QueueSortType.song, 27 | order: QueueSortOrder.descending, 28 | }, 29 | { 30 | label: `$(circuit-board) ${i18n.word.album}`, 31 | description: i18n.word.ascending, 32 | type: QueueSortType.album, 33 | order: QueueSortOrder.ascending, 34 | }, 35 | { 36 | label: `$(circuit-board) ${i18n.word.album}`, 37 | description: i18n.word.descending, 38 | type: QueueSortType.album, 39 | order: QueueSortOrder.descending, 40 | }, 41 | { 42 | label: `$(account) ${i18n.word.artist}`, 43 | description: i18n.word.ascending, 44 | type: QueueSortType.artist, 45 | order: QueueSortOrder.ascending, 46 | }, 47 | { 48 | label: `$(account) ${i18n.word.artist}`, 49 | description: i18n.word.descending, 50 | type: QueueSortType.artist, 51 | order: QueueSortOrder.descending, 52 | }, 53 | ], 54 | }); 55 | 56 | IPC.new(QueueProvider.sort(pick.type, pick.order)); 57 | return input.stay(); 58 | }); 59 | }), 60 | 61 | commands.registerCommand("cloudmusic.clearQueue", IPC.clear), 62 | 63 | commands.registerCommand("cloudmusic.randomQueue", IPC.random), 64 | 65 | commands.registerCommand("cloudmusic.playSong", ({ valueOf }: QueueContent) => IPC.playSong(valueOf)), 66 | 67 | commands.registerCommand("cloudmusic.deleteSong", ({ valueOf }: QueueContent) => IPC.delete(valueOf)), 68 | 69 | commands.registerCommand("cloudmusic.playNext", ({ data }: QueueContent) => IPC.add([data], 1)), 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /packages/client/src/activate/radio.ts: -------------------------------------------------------------------------------- 1 | import { IPC, MultiStepInput, Webview, pickRadio } from "../utils/index.js"; 2 | import type { ProgramTreeItem, RadioTreeItem, UserTreeItem } from "../treeview/index.js"; 3 | import { commands, env } from "vscode"; 4 | import type { ExtensionContext } from "vscode"; 5 | import { NeteaseCommentType } from "@cloudmusic/shared"; 6 | import { RadioProvider } from "../treeview/index.js"; 7 | 8 | export function initRadio(context: ExtensionContext): void { 9 | context.subscriptions.push( 10 | commands.registerCommand("cloudmusic.refreshRadio", (element: UserTreeItem) => RadioProvider.refreshUser(element)), 11 | 12 | commands.registerCommand("cloudmusic.refreshRadioContent", (element: RadioTreeItem) => 13 | RadioProvider.refreshRadioHard(element), 14 | ), 15 | 16 | commands.registerCommand("cloudmusic.playRadio", async (element: RadioTreeItem) => 17 | IPC.new(await RadioProvider.refreshRadio(element)), 18 | ), 19 | 20 | commands.registerCommand("cloudmusic.addRadio", async (element: RadioTreeItem) => 21 | IPC.add(await RadioProvider.refreshRadio(element)), 22 | ), 23 | 24 | commands.registerCommand("cloudmusic.unsubRadio", ({ item: { id } }: RadioTreeItem) => 25 | IPC.netease("djSub", [id, "unsub"]), 26 | ), 27 | 28 | commands.registerCommand( 29 | "cloudmusic.radioDetail", 30 | ({ item }: RadioTreeItem) => void MultiStepInput.run((input) => pickRadio(input, 1, item)), 31 | ), 32 | 33 | commands.registerCommand("cloudmusic.addProgram", ({ data }: ProgramTreeItem) => IPC.add([data])), 34 | 35 | commands.registerCommand( 36 | "cloudmusic.copyRadioLink", 37 | ({ item: { id } }: RadioTreeItem) => void env.clipboard.writeText(`https://music.163.com/#/djradio?id=${id}`), 38 | ), 39 | 40 | commands.registerCommand("cloudmusic.programComment", ({ data: { id }, label }: ProgramTreeItem) => 41 | Webview.comment(NeteaseCommentType.dj, id, label), 42 | ), 43 | 44 | commands.registerCommand( 45 | "cloudmusic.copyProgramLink", 46 | ({ data: { id } }: ProgramTreeItem) => void env.clipboard.writeText(`https://music.163.com/#/program?id=${id}`), 47 | ), 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/client/src/activate/statusBar.ts: -------------------------------------------------------------------------------- 1 | import { IPC, LyricType, MultiStepInput, STATE, Webview, pickUser } from "../utils/index.js"; 2 | import { BUTTON_MANAGER } from "../manager/index.js"; 3 | import type { ExtensionContext } from "vscode"; 4 | import type { InputStep } from "../utils/index.js"; 5 | import { commands } from "vscode"; 6 | import i18n from "../i18n/index.js"; 7 | 8 | export function initStatusBar(context: ExtensionContext): void { 9 | BUTTON_MANAGER.init(); 10 | 11 | context.subscriptions.push( 12 | commands.registerCommand("cloudmusic.lyric", async () => { 13 | const totalSteps = 2; 14 | const title = i18n.word.lyric; 15 | let select = ""; 16 | 17 | const enum Type { 18 | delay, 19 | type, 20 | all, 21 | cache, 22 | disable, 23 | user, 24 | font, 25 | panel, 26 | } 27 | 28 | await MultiStepInput.run(async (input) => { 29 | const user = STATE.lyric.user[<0 | 1>STATE.lyric.type]; 30 | const { type } = await input.showQuickPick({ 31 | title, 32 | step: 1, 33 | totalSteps, 34 | items: [ 35 | { 36 | label: `$(versions) ${i18n.word.lyricDelay}`, 37 | description: `${i18n.sentence.label.lyricDelay} (${i18n.word.default}: -1.0)`, 38 | type: Type.delay, 39 | }, 40 | { 41 | label: `$(symbol-type-parameter) ${ 42 | STATE.lyric.type === LyricType.ori 43 | ? i18n.word.original 44 | : STATE.lyric.type === LyricType.tra 45 | ? i18n.word.translation 46 | : i18n.word.romanization 47 | }`, 48 | type: Type.type, 49 | }, 50 | { label: `$(list-ordered) ${i18n.word.fullLyric}`, type: Type.all }, 51 | { label: `$(trash) ${i18n.word.cleanCache}`, type: Type.cache }, 52 | { 53 | label: STATE.showLyric 54 | ? `$(circle-slash) ${i18n.word.disable}` 55 | : `$(circle-large-outline) ${i18n.word.enable}`, 56 | type: Type.disable, 57 | }, 58 | ...(user ? [{ label: `$(account) ${i18n.word.user}`, type: Type.user }] : []), 59 | { label: `$(book) ${i18n.sentence.label.showInEditor}`, type: Type.panel }, 60 | ], 61 | }); 62 | switch (type) { 63 | case Type.delay: 64 | return (input) => inputDelay(input); 65 | case Type.all: 66 | return (input) => pickLyric(input); 67 | case Type.type: 68 | STATE.lyric.type = (STATE.lyric.type + 1) % 3; 69 | break; 70 | case Type.cache: 71 | IPC.lyric(); 72 | break; 73 | case Type.disable: 74 | STATE.showLyric = !STATE.showLyric; 75 | break; 76 | case Type.user: 77 | return (input) => pickUser(input, 2, user?.userid || 0); 78 | case Type.panel: 79 | Webview.lyric(); 80 | break; 81 | } 82 | return input.stay(); 83 | }); 84 | 85 | async function inputDelay(input: MultiStepInput): Promise { 86 | const delay = await input.showInputBox({ title, step: 2, totalSteps, prompt: i18n.sentence.hint.lyricDelay }); 87 | if (/^-?[0-9]+([.]{1}[0-9]+){0,1}$/.test(delay)) IPC.lyricDelay(parseFloat(delay)); 88 | return input.stay(); 89 | } 90 | 91 | async function pickLyric(input: MultiStepInput): Promise { 92 | const pick = await input.showQuickPick({ 93 | title, 94 | step: 2, 95 | totalSteps: totalSteps + 1, 96 | items: STATE.lyric.time 97 | .map((time, i) => ({ label: STATE.lyric.text[i][STATE.lyric.type], description: `${time}` })) 98 | .filter(({ label }) => label), 99 | }); 100 | select = pick.label; 101 | return (input) => showLyric(input); 102 | } 103 | 104 | async function showLyric(input: MultiStepInput) { 105 | await input.showInputBox({ title, step: 3, totalSteps: totalSteps + 1, value: select }); 106 | } 107 | }), 108 | 109 | commands.registerCommand("cloudmusic.fmTrash", () => { 110 | if (STATE.fmUid && typeof STATE.playItem?.valueOf === "number") { 111 | void IPC.netease("fmTrash", [STATE.playItem.valueOf]); 112 | void commands.executeCommand("cloudmusic.next"); 113 | } 114 | }), 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /packages/client/src/constant/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./shared.js"; 2 | 3 | import type { WorkspaceConfiguration } from "vscode"; 4 | import { homedir } from "node:os"; 5 | import { resolve } from "node:path"; 6 | import { workspace } from "vscode"; 7 | 8 | export const CONF = (): WorkspaceConfiguration => workspace.getConfiguration("cloudmusic"); 9 | 10 | const kConf = CONF(); 11 | 12 | export const SETTING_DIR = kConf.get("cache.path") || resolve(homedir(), ".cloudmusic"); 13 | export const MUSIC_CACHE_DIR = resolve(SETTING_DIR, "cache", "music"); 14 | 15 | export const AUTO_START = kConf.get("host.autoStart", false); 16 | export const AUTO_CHECK = kConf.get("account.autoCheck", false); 17 | export const MUSIC_QUALITY = (conf: WorkspaceConfiguration): number => 18 | conf.get<128000 | 192000 | 320000 | 999000>("music.quality", 192000); 19 | export const MUSIC_CACHE_SIZE = (conf: WorkspaceConfiguration): number => conf.get("cache.size", 4096) * 1024 * 1024; 20 | export const PROXY = kConf.get("network.proxy", ""); 21 | export const STRICT_SSL = kConf.get("network.strictSSL", true); 22 | export const HTTPS_API = (conf: WorkspaceConfiguration): boolean => conf.get("network.httpsAPI", true); 23 | export const FOREIGN = (conf: WorkspaceConfiguration): boolean => conf.get("network.foreignUser", false); 24 | export const PLAYER_MODE = kConf.get<"auto" | "native" | "wasm">("player.mode", "auto"); 25 | export const QUEUE_INIT = kConf.get<"none" | "recommend" | "restore">("queue.initialization", "none"); 26 | 27 | export const ACCOUNT_KEY = "account-v3"; 28 | export const CACHE_KEY = "cache-v4"; 29 | export const LYRIC_CACHE_KEY = "lyric-cache-v6"; 30 | export const COOKIE_KEY = "cookie-v5"; 31 | export const BUTTON_KEY = "button-v2"; 32 | export const SPEED_KEY = "speed"; 33 | export const VOLUME_KEY = "volume"; 34 | export const LYRIC_KEY = "lyric-v3"; 35 | export const LOCAL_FOLDER_KEY = "local-folder-v2"; 36 | export const REPEAT_KEY = "repeat-v1"; 37 | export const FM_KEY = "fm-v2"; 38 | export const SHOW_LYRIC_KEY = "show-lyric-v1"; 39 | 40 | // export const AUTH_PROVIDER_ID = "cloudmusic-auth-provider"; 41 | -------------------------------------------------------------------------------- /packages/client/src/constant/shared.ts: -------------------------------------------------------------------------------- 1 | import { ipcAppspace, ipcBroadcastServerId, ipcServerId } from "@cloudmusic/shared"; 2 | 3 | export const ipcServerPath = 4 | process.platform === "win32" ? `\\\\.\\pipe\\tmp-${ipcAppspace}${ipcServerId}` : `/tmp/${ipcAppspace}${ipcServerId}`; 5 | 6 | export const ipcBroadcastServerPath = 7 | process.platform === "win32" 8 | ? `\\\\.\\pipe\\tmp-${ipcAppspace}${ipcBroadcastServerId}` 9 | : `/tmp/${ipcAppspace}${ipcBroadcastServerId}`; 10 | 11 | export const NATIVE_MODULE = `${process.platform}-${process.arch}.node`; 12 | -------------------------------------------------------------------------------- /packages/client/src/extension.ts: -------------------------------------------------------------------------------- 1 | import { AUTO_START, BUTTON_KEY, NATIVE_MODULE, SETTING_DIR } from "./constant/index.js"; 2 | import { CONTEXT, IPC, STATE } from "./utils/index.js"; 3 | import type { Disposable, ExtensionContext, TreeDataProvider, TreeView, TreeViewVisibilityChangeEvent } from "vscode"; 4 | import { LocalProvider, PlaylistProvider, QueueProvider, RadioProvider } from "./treeview/index.js"; 5 | import { Uri, window, workspace } from "vscode"; 6 | import i18n from "./i18n/index.js"; 7 | import { mkdir } from "node:fs/promises"; 8 | import { realActivate } from "./activate/index.js"; 9 | 10 | export async function activate(context: ExtensionContext): Promise { 11 | CONTEXT.context = context; 12 | /* process.setUncaughtExceptionCaptureCallback(({ message }) => 13 | console.error(message) 14 | ); */ 15 | process.on("unhandledRejection", console.error); 16 | await mkdir(SETTING_DIR, { recursive: true }).catch(); 17 | 18 | context.globalState.setKeysForSync([BUTTON_KEY]); 19 | 20 | // Check mode 21 | if (!STATE.wasm) { 22 | const buildUri = Uri.joinPath(context.extensionUri, "build"); 23 | const files = await workspace.fs.readDirectory(buildUri); 24 | STATE.wasm = files.findIndex(([file]) => file === NATIVE_MODULE) === -1; 25 | if (!STATE.wasm) STATE.downInit(); // 3 26 | } 27 | console.log("Cloudmusic:", STATE.wasm ? "wasm" : "native", "mode."); 28 | 29 | const createTreeView = (viewId: string, treeDataProvider: TreeDataProvider & { view: TreeView }) => { 30 | const view = window.createTreeView(viewId, { treeDataProvider }); 31 | treeDataProvider.view = view; 32 | return view; 33 | }; 34 | 35 | const queue = createTreeView("cloudmusic-queue", QueueProvider.getInstance()); 36 | const local = createTreeView("cloudmusic-local", LocalProvider.getInstance()); 37 | const playlist = createTreeView("cloudmusic-playlist", PlaylistProvider.getInstance()); 38 | const radio = createTreeView("cloudmusic-radio", RadioProvider.getInstance()); 39 | context.subscriptions.push(queue, local, playlist, radio); 40 | 41 | // Only checking the visibility of the queue treeview is enough. 42 | if (AUTO_START || queue.visible) await realActivate(context); 43 | else { 44 | let done = false; 45 | { 46 | let disposable: Disposable | undefined = undefined; 47 | const callback = ({ visible }: TreeViewVisibilityChangeEvent) => { 48 | if (!visible) return; 49 | disposable?.dispose(); 50 | disposable = undefined; 51 | if (!done) { 52 | done = true; 53 | void realActivate(context); 54 | } 55 | }; 56 | disposable = queue.onDidChangeVisibility(callback); 57 | } 58 | 59 | { 60 | let shown = false; 61 | for (const view of [local, playlist, radio]) { 62 | let disposable: Disposable | undefined = undefined; 63 | const callback2 = ({ visible }: TreeViewVisibilityChangeEvent) => { 64 | if (done) { 65 | disposable?.dispose(); 66 | disposable = undefined; 67 | return; 68 | } 69 | if (!visible) return; 70 | 71 | setTimeout(() => { 72 | disposable?.dispose(); 73 | disposable = undefined; 74 | if (!done && !shown) { 75 | shown = true; 76 | void window.showInformationMessage(i18n.sentence.hint.expandView); 77 | } 78 | }, 256); 79 | }; 80 | 81 | disposable = view.onDidChangeVisibility(callback2); 82 | } 83 | } 84 | } 85 | } 86 | 87 | export function deactivate(): Promise { 88 | if (STATE.master) IPC.retain(QueueProvider.songs); 89 | // On windows, the data will be lost when the PIPE is closed. 90 | if (process.platform !== "win32") return Promise.resolve(IPC.disconnect()); 91 | else return new Promise((resolve) => setTimeout(() => resolve(IPC.disconnect()), 2048)); 92 | } 93 | -------------------------------------------------------------------------------- /packages/client/src/i18n/en.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | sentence: { 3 | error: { 4 | needSignIn: "Please operate after signing in", 5 | network: "Network error", 6 | }, 7 | fail: { 8 | addToPlaylist: "Failed to add to playlist", 9 | signIn: "Sign in failed", 10 | }, 11 | hint: { 12 | account: "Please enter your account", 13 | button: "Set whether the button is displayed or not", 14 | captcha: "Please enter your captcha", 15 | confirmation: "Are you sure you want to do this? ", 16 | countrycode: "Please enter your countrycode", 17 | desc: "Please enter description", 18 | keyword: "Please enter keyword", 19 | lyricDelay: "Please enter lyric delay", 20 | lyricFontSize: "Please enter lyric font size", 21 | name: "Please enter the name", 22 | noUnplayable: "Did not add unplayable songs", 23 | password: "Please enter your password", 24 | search: "Please choose search type", 25 | signIn: "Select the method to sign in", 26 | speed: "Please enter the speed", 27 | trySignIn: "Found that you have not logged in", 28 | volume: "Please enter the volume", 29 | expandView: "Expand Queue to activate extension", 30 | }, 31 | label: { 32 | captcha: "Use captcha to sign in", 33 | cellphone: "Use cellphone to sign in", 34 | dailyRecommendedPlaylists: "Daily recommended playlists", 35 | dailyRecommendedSongs: "Daily recommended songs", 36 | email: "Use email to sign in", 37 | lyricDelay: "Set lyric delay", 38 | newsongRecommendation: "New song recommendation", 39 | playlistRecommendation: "Playlist recommendation", 40 | programRecommendation: "Program recommendation", 41 | qrcode: "Use QR Code to sign in", 42 | radioRecommendation: "Radio recommendation", 43 | showInEditor: "Show in editor", 44 | }, 45 | success: { 46 | addToPlaylist: "Added to playlist", 47 | dailyCheck: "Daily check successful", 48 | signIn: "Sign in successful", 49 | }, 50 | warn: { 51 | login: "Recommend to log in via QR code, other methods are more likely to fail", 52 | }, 53 | }, 54 | word: { 55 | account: "Account", 56 | addToQueue: "Add to queue", 57 | album: "Album", 58 | albumNewest: "Album newest", 59 | all: "All", 60 | allTime: "All time", 61 | area: "Area", 62 | artist: "Artist", 63 | artistList: "Artist list", 64 | ascending: "Ascending", 65 | band: "Band", 66 | captcha: "Captcha", 67 | categorie: "Categorie", 68 | cellphone: "Cellphone", 69 | cleanCache: "Clean cache", 70 | close: "Close", 71 | comment: "Comment", 72 | confirmation: "Confirmation", 73 | content: "Content", 74 | copyLink: "Copy link", 75 | createPlaylist: "Create", 76 | default: "Default", 77 | descending: "Descending", 78 | description: "Description", 79 | detail: "Detail", 80 | disable: "Disable", 81 | // disabled: "Disabled", 82 | download: "Download", 83 | editPlaylist: "Edit", 84 | email: "Email", 85 | en: "English", 86 | enable: "Enable", 87 | explore: "Explore", 88 | female: "Female artist", 89 | followeds: "Followeds", 90 | follows: "Follows", 91 | // fontSize: "Font size", 92 | forward: "Forword", 93 | fullLyric: "All lyrics", 94 | hide: "Hide", 95 | highqualityPlaylist: "Highquality playlist", 96 | hot: "Hot", 97 | hotSongs: "Hot songs", 98 | hottest: "Hottest", 99 | initial: "Initial", 100 | ja: "Japanese", 101 | kr: "Korean", 102 | latest: "Latest", 103 | like: "Like", 104 | liked: "Liked", 105 | loading: "Loading", 106 | lyric: "Lyric", 107 | lyricDelay: "Lyric delay", 108 | male: "Male artist", 109 | more: "More", 110 | musicRanking: "Music ranking", 111 | new: "New", 112 | nextPage: "Next Page", 113 | nextTrack: "Next track", 114 | original: "Original", 115 | other: "Other", 116 | pause: "Pause", 117 | personalFm: "Personal FM", 118 | play: "Play", 119 | playCount: "Play count", 120 | playlist: "Playlist", 121 | previousPage: "Previous page", 122 | previousTrack: "Previous track", 123 | private: "Private", 124 | program: "Program", 125 | public: "Public", 126 | qrcode: "QR Code", 127 | radio: "Radio", 128 | radioHot: "Popular radio", 129 | recommendation: "Recommendation", 130 | refresh: "Refresh", 131 | refreshing: "Refreshing", 132 | repeat: "Repeat", 133 | reply: "Reply", 134 | romanization: "Romanization", 135 | save: "Save", 136 | saved: "Saved", 137 | saveToPlaylist: "Save to playlist", 138 | search: "Search", 139 | seekbackward: "Seek backward", 140 | seekforward: "Seek forward", 141 | show: "Show", 142 | signIn: "Sign in", 143 | signOut: "Sign out", 144 | similarArtists: "Similar artists", 145 | similarPlaylists: "Similar playlists", 146 | similarSongs: "Similar songs", 147 | single: "Single", 148 | song: "Song", 149 | songList: "Song list", 150 | speed: "Speed", 151 | submit: "Submit", 152 | subscribedCount: "Subscribed count", 153 | today: "24 Hours", 154 | topAlbums: "New discs on shelves", 155 | topArtists: "Popular artists", 156 | toplist: "Toplist", 157 | topSong: "New song express", 158 | trackCount: "Track count", 159 | translation: "Translation", 160 | trash: "Trash", 161 | type: "Type", 162 | unliked: "Unliked", 163 | unsave: "Unsave", 164 | user: "User", 165 | volume: "Volume", 166 | weekly: "Weekly", 167 | zh: "Chinese", 168 | }, 169 | }; 170 | -------------------------------------------------------------------------------- /packages/client/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import en from "./en.js"; 2 | import { env } from "vscode"; 3 | import zhCn from "./zh-cn.js"; 4 | import zhTw from "./zh-tw.js"; 5 | 6 | type AvailableLanguages = "en" | "zh-cn" | "zh-tw"; 7 | 8 | const availableLanguages: readonly Exclude[] = ["zh-cn", "zh-tw"]; 9 | 10 | const lang = availableLanguages.find((value) => env.language === value); 11 | 12 | const i18n = (() => { 13 | switch (lang) { 14 | case "zh-cn": 15 | return zhCn; 16 | case "zh-tw": 17 | return zhTw; 18 | default: 19 | return en; 20 | } 21 | })(); 22 | 23 | export default i18n; 24 | -------------------------------------------------------------------------------- /packages/client/src/i18n/zh-cn.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | sentence: { 3 | error: { 4 | needSignIn: "请在登录后操作", 5 | network: "网络错误", 6 | }, 7 | fail: { 8 | addToPlaylist: "添加到歌单失败", 9 | signIn: "登录失败", 10 | }, 11 | hint: { 12 | account: "请输入账号", 13 | button: "设置是否显示按钮", 14 | captcha: "请输入验证码", 15 | confirmation: "确定要执行此操作吗?", 16 | countrycode: "请输入国家码", 17 | desc: "请输入描述", 18 | keyword: "请输入关键词", 19 | lyricDelay: "请输入歌词延迟", 20 | lyricFontSize: "请输入歌词字体大小", 21 | name: "请输入名称", 22 | noUnplayable: "没有添加无法播放的歌曲", 23 | password: "请输入密码", 24 | search: "请选择搜索类型", 25 | signIn: "请选择登录方式", 26 | speed: "请输入播放速度", 27 | trySignIn: "发现到您还没登录", 28 | volume: "请输入音量", 29 | expandView: "展开队列激活拓展", 30 | }, 31 | label: { 32 | captcha: "使用验证码登录", 33 | cellphone: "使用手机登录", 34 | dailyRecommendedPlaylists: "每日推荐歌单", 35 | dailyRecommendedSongs: "每日推荐歌曲", 36 | email: "使用邮箱登录", 37 | lyricDelay: "设置歌词延迟", 38 | newsongRecommendation: "新歌推荐", 39 | playlistRecommendation: "歌单推荐", 40 | programRecommendation: "推荐节目", 41 | qrcode: "使用二维码登录", 42 | radioRecommendation: "精选播单", 43 | showInEditor: "在编辑器中显示", 44 | }, 45 | success: { 46 | addToPlaylist: "添加到歌单", 47 | dailyCheck: "签到成功", 48 | signIn: "登录成功", 49 | }, 50 | warn: { 51 | login: "推荐使用二维码登录,其他方式失败的概率较大", 52 | }, 53 | }, 54 | word: { 55 | account: "帐号", 56 | addToQueue: "添加到队列", 57 | album: "专辑", 58 | albumNewest: "最新专辑", 59 | all: "全部", 60 | allTime: "所有时间", 61 | area: "地区", 62 | artist: "歌手", 63 | artistList: "歌手榜", 64 | ascending: "升序", 65 | band: "乐队", 66 | captcha: "验证码", 67 | categorie: "类别", 68 | cellphone: "手机", 69 | cleanCache: "清除缓存", 70 | close: "关闭", 71 | comment: "评论", 72 | confirmation: "确认", 73 | content: "内容", 74 | copyLink: "复制链接", 75 | createPlaylist: "新建", 76 | default: "默认", 77 | descending: "降序", 78 | description: "描述", 79 | detail: "详情", 80 | disable: "禁用", 81 | // disabled: "已禁用", 82 | download: "下载", 83 | editPlaylist: "编辑", 84 | email: "邮箱", 85 | en: "欧美", 86 | enable: "开启", 87 | explore: "发现", 88 | female: "女歌手", 89 | followeds: "粉丝", 90 | follows: "关注", 91 | // fontSize: "字体大小", 92 | forward: "前进", 93 | fullLyric: "全部歌词", 94 | hide: "隐藏", 95 | highqualityPlaylist: "精品歌单", 96 | hot: "热门", 97 | hotSongs: "热门歌曲", 98 | hottest: "热门", 99 | initial: "起始", 100 | ja: "日本", 101 | kr: "韩国", 102 | latest: "最新", 103 | like: "喜欢", 104 | liked: "已喜欢", 105 | loading: "加载中", 106 | lyric: "歌词", 107 | lyricDelay: "歌词延迟", 108 | male: "男歌手", 109 | more: "更多", 110 | musicRanking: "听歌排行", 111 | new: "新晋", 112 | nextPage: "下一页", 113 | nextTrack: "下一首", 114 | original: "原文", 115 | other: "其他", 116 | pause: "暂停", 117 | personalFm: "私人 FM", 118 | play: "播放", 119 | playCount: "播放量", 120 | playlist: "歌单", 121 | previousPage: "上一页", 122 | previousTrack: "前一首", 123 | private: "私密", 124 | program: "节目", 125 | public: "公开", 126 | qrcode: "二维码", 127 | radio: "播单", 128 | radioHot: "热门播单", 129 | recommendation: "推荐", 130 | refresh: "刷新", 131 | refreshing: "刷新中", 132 | repeat: "单曲循环", 133 | reply: "回复", 134 | romanization: "罗马音", 135 | save: "收藏", 136 | saved: "已收藏", 137 | saveToPlaylist: "收藏到歌单", 138 | search: "搜索", 139 | seekbackward: "向后搜索", 140 | seekforward: "向前搜索", 141 | show: "显示", 142 | signIn: "登录", 143 | signOut: "登出", 144 | similarArtists: "相似歌手", 145 | similarPlaylists: "相似歌单", 146 | similarSongs: "相似歌曲", 147 | single: "单曲", 148 | song: "歌曲", 149 | songList: "音乐榜", 150 | speed: "播放速度", 151 | submit: "提交", 152 | subscribedCount: "收藏数", 153 | today: "24小时", 154 | topAlbums: "新碟上架", 155 | topArtists: "热门歌手", 156 | toplist: "排行榜", 157 | topSong: "新歌速递", 158 | trackCount: "单曲数", 159 | translation: "翻译", 160 | trash: "垃圾桶", 161 | type: "种类", 162 | unliked: "已取消喜欢", 163 | unsave: "取消收藏", 164 | user: "用户", 165 | volume: "音量", 166 | weekly: "每周", 167 | zh: "华语", 168 | }, 169 | }; 170 | -------------------------------------------------------------------------------- /packages/client/src/i18n/zh-tw.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | sentence: { 3 | error: { 4 | needSignIn: "請在登入後操作", 5 | network: "網絡錯誤", 6 | }, 7 | fail: { 8 | addToPlaylist: "添加到歌單失敗", 9 | signIn: "登入失敗", 10 | }, 11 | hint: { 12 | account: "請輸入帳號", 13 | button: "設定是否顯示按鈕", 14 | captcha: "請輸入驗證碼", 15 | confirmation: "確定要執行此操作嗎?", 16 | countrycode: "請輸入國家碼", 17 | desc: "請輸入描述", 18 | keyword: "請輸入關鍵字", 19 | lyricDelay: "請輸入歌詞延遲", 20 | lyricFontSize: "請輸入歌詞字體大小", 21 | name: "請輸入名稱", 22 | noUnplayable: "沒有添加無法播放的歌曲", 23 | password: "請輸入密碼", 24 | search: "請選擇搜索類型", 25 | signIn: "請選擇登入管道", 26 | speed: "請輸入播放速度", 27 | trySignIn: "發現到您還沒登入", 28 | volume: "請輸入音量", 29 | expandView: "展開隊列激活拓展", 30 | }, 31 | label: { 32 | captcha: "使用驗證碼登錄", 33 | cellphone: "使用手機登錄", 34 | dailyRecommendedPlaylists: "每日推薦歌單", 35 | dailyRecommendedSongs: "每日推薦歌曲", 36 | email: "使用郵箱登錄", 37 | lyricDelay: "設定歌詞延遲", 38 | newsongRecommendation: "新歌推薦", 39 | playlistRecommendation: "歌單推薦", 40 | programRecommendation: "推薦節目", 41 | qrcode: "使用二維碼登錄", 42 | radioRecommendation: "精選播單", 43 | showInEditor: "在編輯器中顯示", 44 | }, 45 | success: { 46 | addToPlaylist: "添加到歌單", 47 | dailyCheck: "簽到成功", 48 | signIn: "登入成功", 49 | }, 50 | warn: { 51 | login: "推薦使用二維碼登錄,其他方式失敗的概率較大", 52 | }, 53 | }, 54 | word: { 55 | account: "帳號", 56 | addToQueue: "添加到隊列", 57 | album: "專輯", 58 | albumNewest: "最新專輯", 59 | all: "全部", 60 | allTime: "所有時間", 61 | area: "地區", 62 | artist: "歌手", 63 | artistList: "歌手榜", 64 | ascending: "昇冪", 65 | band: "樂團", 66 | captcha: "驗證碼", 67 | categorie: "類別", 68 | cellphone: "手機", 69 | cleanCache: "清除緩存", 70 | close: "關閉", 71 | comment: "評論", 72 | confirmation: "確認", 73 | content: "內容", 74 | copyLink: "複製連結", 75 | createPlaylist: "新建", 76 | default: "默認", 77 | descending: "降序", 78 | description: "描述", 79 | detail: "詳情", 80 | disable: "禁用", 81 | // disabled: "已禁用", 82 | download: "下載", 83 | editPlaylist: "編輯", 84 | email: "郵箱", 85 | en: "歐美", 86 | enable: "開啓", 87 | explore: "發現", 88 | female: "女歌手", 89 | followeds: "粉絲", 90 | follows: "關注", 91 | // fontSize: "字體大小", 92 | forward: "前進", 93 | fullLyric: "全部歌詞", 94 | hide: "隱藏", 95 | highqualityPlaylist: "精品歌單", 96 | hot: "熱門", 97 | hotSongs: "熱門歌曲", 98 | hottest: "熱門", 99 | initial: "起始", 100 | ja: "日本", 101 | kr: "韓國", 102 | latest: "最新", 103 | like: "喜歡", 104 | liked: "已喜歡", 105 | loading: "加載中", 106 | lyric: "歌詞", 107 | lyricDelay: "歌詞延遲", 108 | male: "男歌手", 109 | more: "更多", 110 | musicRanking: "聽歌排行", 111 | new: "新晉", 112 | nextPage: "下一頁", 113 | nextTrack: "下一首", 114 | original: "原文", 115 | other: "其他", 116 | pause: "暫停", 117 | personalFm: "私人 FM", 118 | play: "播放", 119 | playCount: "播放量", 120 | playlist: "歌單", 121 | previousPage: "上一頁", 122 | previousTrack: "前一首", 123 | private: "私密", 124 | program: "節目", 125 | public: "公開", 126 | qrcode: "二維碼", 127 | radio: "播單", 128 | radioHot: "熱門播單", 129 | recommendation: "推薦", 130 | refresh: "重繪", 131 | refreshing: "重繪中", 132 | repeat: "單曲循環", 133 | romanization: "羅馬音", 134 | reply: "回復", 135 | save: "收藏", 136 | saved: "已收藏", 137 | saveToPlaylist: "收藏到歌單", 138 | search: "搜索", 139 | seekbackward: "向後搜索", 140 | seekforward: "向前搜索", 141 | show: "顯示", 142 | signIn: "登入", 143 | signOut: "登出", 144 | similarArtists: "相似歌手", 145 | similarPlaylists: "相似歌單", 146 | similarSongs: "相似歌曲", 147 | single: "單曲", 148 | song: "歌曲", 149 | songList: "音樂榜", 150 | speed: "播放速度", 151 | submit: "提交", 152 | subscribedCount: "收藏數", 153 | today: "24小時", 154 | topAlbums: "新碟上架", 155 | topArtists: "熱門歌手", 156 | toplist: "排行榜", 157 | topSong: "新歌速遞", 158 | trackCount: "單曲數", 159 | translation: "翻譯", 160 | trash: "垃圾桶", 161 | type: "種類", 162 | unliked: "已取消喜歡", 163 | unsave: "取消收藏", 164 | user: "用戶", 165 | volume: "音量", 166 | weekly: "每週", 167 | zh: "華語", 168 | }, 169 | }; 170 | -------------------------------------------------------------------------------- /packages/client/src/manager/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./account.js"; 2 | export * from "./button.js"; 3 | -------------------------------------------------------------------------------- /packages/client/src/treeview/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./radio.js"; 2 | export * from "./local.js"; 3 | export * from "./playlist.js"; 4 | export * from "./queue.js"; 5 | export * from "./user.js"; 6 | 7 | import type { 8 | LocalFileTreeItem, 9 | LocalFileTreeItemData, 10 | ProgramTreeItem, 11 | ProgramTreeItemData, 12 | QueueItemTreeItem, 13 | QueueItemTreeItemData, 14 | } from "./index.js"; 15 | import type { ThemeIcon, TreeItem } from "vscode"; 16 | 17 | export type QueueContent = QueueItemTreeItem | LocalFileTreeItem | ProgramTreeItem; 18 | 19 | export type TreeItemId = "q" | "p" | "l"; 20 | 21 | export type PlayTreeItemData = LocalFileTreeItemData | ProgramTreeItemData | QueueItemTreeItemData; 22 | 23 | export interface PlayTreeItem extends TreeItem { 24 | readonly iconPath: ThemeIcon; 25 | readonly contextValue: string; 26 | readonly label: string; 27 | readonly description: string; 28 | readonly tooltip: string; 29 | readonly data: PlayTreeItemData; 30 | valueOf: number | string; 31 | } 32 | -------------------------------------------------------------------------------- /packages/client/src/treeview/playlist.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode"; 2 | import { QueueItemTreeItem, UserTreeItem } from "./index.js"; 3 | import type { TreeDataProvider, TreeView } from "vscode"; 4 | import { AccountManager } from "../manager/index.js"; 5 | import { IPC } from "../utils/index.js"; 6 | import type { NeteaseTypings } from "api"; 7 | import type { PlayTreeItemData } from "./index.js"; 8 | import i18n from "../i18n/index.js"; 9 | 10 | type Content = UserTreeItem | PlaylistItemTreeItem | QueueItemTreeItem; 11 | 12 | export class PlaylistProvider implements TreeDataProvider { 13 | private static _instance: PlaylistProvider; 14 | 15 | private static readonly _actions = new WeakMap< 16 | PlaylistItemTreeItem, 17 | { resolve: (value: PlayTreeItemData[]) => void; reject: () => void } 18 | >(); 19 | 20 | readonly view!: TreeView; 21 | 22 | _onDidChangeTreeData = new EventEmitter(); 23 | 24 | readonly onDidChangeTreeData = this._onDidChangeTreeData.event; 25 | 26 | static getInstance(): PlaylistProvider { 27 | return this._instance || (this._instance = new PlaylistProvider()); 28 | } 29 | 30 | static refresh(): void { 31 | this._instance._onDidChangeTreeData.fire(); 32 | } 33 | 34 | static refreshUser(element: UserTreeItem): void { 35 | IPC.deleteCache(`user_playlist${element.uid}`); 36 | this._instance._onDidChangeTreeData.fire(element); 37 | void this._instance.view.reveal(element, { expand: true }); 38 | } 39 | 40 | static async refreshPlaylist(element: PlaylistItemTreeItem): Promise { 41 | const old = this._actions.get(element); 42 | old?.reject(); 43 | return new Promise((resolve, reject) => { 44 | this._actions.set(element, { resolve, reject }); 45 | this._instance._onDidChangeTreeData.fire(element); 46 | void this._instance.view.reveal(element, { expand: true }); 47 | }); 48 | } 49 | 50 | static refreshPlaylistHard(element: PlaylistItemTreeItem): void { 51 | IPC.deleteCache(`playlist_detail${element.valueOf}`); 52 | this._instance._onDidChangeTreeData.fire(element); 53 | void this._instance.view.reveal(element, { expand: true }); 54 | } 55 | 56 | getTreeItem(element: Content): Content { 57 | return element; 58 | } 59 | 60 | async getChildren( 61 | element?: UserTreeItem | PlaylistItemTreeItem, 62 | ): Promise { 63 | if (!element) { 64 | const accounts = []; 65 | for (const [, { userId, nickname }] of AccountManager.accounts) accounts.push(UserTreeItem.new(nickname, userId)); 66 | return accounts; 67 | } 68 | if (element instanceof UserTreeItem) { 69 | const { uid } = element; 70 | return (await AccountManager.playlist(uid)).map((playlist) => PlaylistItemTreeItem.new(playlist, uid)); 71 | } 72 | const { 73 | uid, 74 | item: { id: pid }, 75 | } = element; 76 | const songs = await IPC.netease("playlistDetail", [uid, pid]); 77 | const ret = songs.map((song) => QueueItemTreeItem.new({ ...song, pid, itemType: "q" })); 78 | const action = PlaylistProvider._actions.get(element); 79 | if (action) { 80 | PlaylistProvider._actions.delete(element); 81 | action.resolve(ret.map(({ data }) => data)); 82 | } 83 | return ret; 84 | } 85 | 86 | getParent(element: Content): undefined | UserTreeItem | PlaylistItemTreeItem { 87 | if (element instanceof UserTreeItem) return; 88 | if (element instanceof PlaylistItemTreeItem) return UserTreeItem.unsafeGet(element.uid); 89 | return PlaylistItemTreeItem.unsafeGet(element.data.pid); 90 | } 91 | } 92 | 93 | export class PlaylistItemTreeItem extends TreeItem { 94 | private static readonly _set = new Map(); 95 | 96 | declare readonly label: string; 97 | 98 | override readonly iconPath = new ThemeIcon("list-ordered"); 99 | 100 | override readonly contextValue = "PlaylistItemTreeItem"; 101 | 102 | constructor( 103 | readonly item: NeteaseTypings.PlaylistItem, 104 | public uid: number, 105 | ) { 106 | super(item.name, TreeItemCollapsibleState.Collapsed); 107 | 108 | this.tooltip = `${i18n.word.description}: ${item.description || ""} 109 | ${i18n.word.trackCount}: ${item.trackCount} 110 | ${i18n.word.playCount}: ${item.playCount} 111 | ${i18n.word.subscribedCount}: ${item.subscribedCount}`; 112 | } 113 | 114 | override get valueOf(): number { 115 | return this.item.id; 116 | } 117 | 118 | static new(item: NeteaseTypings.PlaylistItem, uid = 0): PlaylistItemTreeItem { 119 | let element = this._set.get(item.id); 120 | if (element) { 121 | element.uid = uid; 122 | return element; 123 | } 124 | element = new this(item, uid); 125 | this._set.set(item.id, element); 126 | return element; 127 | } 128 | 129 | static unsafeGet(pid: number): PlaylistItemTreeItem { 130 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 131 | return this._set.get(pid)!; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /packages/client/src/treeview/user.ts: -------------------------------------------------------------------------------- 1 | import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from "vscode"; 2 | 3 | export class UserTreeItem extends TreeItem { 4 | private static readonly _set = new Map(); 5 | 6 | override readonly iconPath = new ThemeIcon("account"); 7 | 8 | override readonly contextValue = "UserTreeItem"; 9 | 10 | constructor( 11 | override readonly label: string, 12 | readonly uid: number, 13 | ) { 14 | super(label, TreeItemCollapsibleState.Collapsed); 15 | } 16 | 17 | static new(label: string, uid: number): UserTreeItem { 18 | let element = this._set.get(uid); 19 | if (element) return element; 20 | element = new this(label, uid); 21 | this._set.set(uid, element); 22 | return element; 23 | } 24 | 25 | static unsafeGet(uid: number): UserTreeItem { 26 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 27 | return this._set.get(uid)!; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/client/src/unblock/filter.ts: -------------------------------------------------------------------------------- 1 | /* import type { SongsItem, UnlockSongItem } from "../constant"; 2 | 3 | export default function filter( 4 | list: UnlockSongItem[], 5 | song: SongsItem 6 | ): UnlockSongItem | undefined { 7 | let newList = list.filter(({ name }) => name === song.name); 8 | if (newList.length > 0) { 9 | list = newList; 10 | } 11 | 12 | newList = list.filter(({ album }) => song.al.name === album); 13 | if (newList.length > 0) { 14 | list = newList; 15 | } 16 | 17 | newList = list.filter(({ artist }) => 18 | song.ar.map(({ name }) => artist.includes(name)).includes(true) 19 | ); 20 | if (newList.length > 0) { 21 | list = newList; 22 | } 23 | 24 | newList = list.filter(({ dt }) => Math.abs(dt - song.dt) < 10000); 25 | 26 | return (newList.length > 0 ? newList : list).shift(); 27 | } 28 | */ 29 | 30 | export default undefined; 31 | -------------------------------------------------------------------------------- /packages/client/src/unblock/index.ts: -------------------------------------------------------------------------------- 1 | /* import type { SongDetail, SongsItem } from "../constant"; 2 | import { UNBLOCK_MUSIC } from "../constant"; 3 | import joox from "./joox"; 4 | import kugou from "./kugou"; 5 | import kuwo from "./kuwo"; 6 | import migu from "./migu"; 7 | 8 | const provider = [ 9 | ...(UNBLOCK_MUSIC.kuwo ? [kuwo] : []), 10 | ...(UNBLOCK_MUSIC.migu ? [migu] : []), 11 | ...(UNBLOCK_MUSIC.kugou ? [kugou] : []), 12 | ...(UNBLOCK_MUSIC.joox ? [joox] : []), 13 | ]; 14 | 15 | export default async function unblock( 16 | song: SongsItem 17 | ): Promise { 18 | return (await Promise.all(provider.map((i) => i(song)))).find((item) => item); 19 | } */ 20 | 21 | export default undefined; 22 | -------------------------------------------------------------------------------- /packages/client/src/unblock/joox.ts: -------------------------------------------------------------------------------- 1 | /* import type { SongDetail, SongsItem, UnlockSongItem } from "../constant"; 2 | import axios from "axios"; 3 | import { extname } from "node:path"; 4 | 5 | interface SearchResult { 6 | tracks: { 7 | album_name: string; 8 | artist_list: { name: string }[]; 9 | id: string; 10 | is_playable: boolean; 11 | name: string; 12 | play_duration: number; 13 | }[][]; 14 | } 15 | 16 | async function search(keyword: string) { 17 | keyword = encodeURIComponent(keyword); 18 | 19 | try { 20 | const { 21 | data: { tracks }, 22 | } = await axios.get( 23 | `http://api-jooxtt.sanook.com/openjoox/v2/search_type?lang=zh_CN&type=0&key=${keyword}` 24 | ); 25 | return ( 26 | tracks 27 | .flat() 28 | // eslint-disable-next-line @typescript-eslint/naming-convention 29 | .filter(({ is_playable }) => is_playable) 30 | // eslint-disable-next-line @typescript-eslint/naming-convention 31 | .map(({ album_name, artist_list, id, name, play_duration }) => ({ 32 | album: album_name, 33 | artist: artist_list.map(({ name }) => name), 34 | id, 35 | name, 36 | dt: play_duration * 1000, 37 | })) 38 | .slice(0, 8) 39 | ); 40 | } catch {} 41 | return []; 42 | } 43 | 44 | async function songUrl({ id }: UnlockSongItem) { 45 | try { 46 | const { data } = await axios.get( 47 | `http://api.joox.com/web-fcgi-bin/web_get_songinfo?songid=${id}&country=hk&lang=zh_CN&from_type=-1&channel_id=-1&_=${Date.now()}`, 48 | { 49 | headers: { 50 | origin: "http://www.joox.com", 51 | referer: "http://www.joox.com", 52 | }, 53 | } 54 | ); 55 | const { r320Url, r192Url, mp3Url } = JSON.parse( 56 | data.slice(data.indexOf("(") + 1, -1) 57 | ) as { 58 | r320Url?: string; 59 | r192Url?: string; 60 | mp3Url?: string; 61 | }; 62 | const url = (r320Url || r192Url || mp3Url)?.replace( 63 | /M\d00([\w]+).mp3/, 64 | "M800$1.mp3" 65 | ); 66 | return url ? { url, type: extname(url).split(".").pop() } : undefined; 67 | } catch {} 68 | return; 69 | } 70 | 71 | export default async function joox( 72 | song: SongsItem 73 | ): Promise { 74 | const list = await search(song.name); 75 | const selected = list.shift(); 76 | return selected ? await songUrl(selected) : undefined; 77 | } 78 | */ 79 | 80 | export default undefined; 81 | -------------------------------------------------------------------------------- /packages/client/src/unblock/kugou.ts: -------------------------------------------------------------------------------- 1 | /* import type { SongDetail, SongsItem, UnlockSongItem } from "../constant"; 2 | import axios from "axios"; 3 | import { createHash } from "node:crypto"; 4 | import { extname } from "node:path"; 5 | import filter from "./filter"; 6 | 7 | interface SearchResult { 8 | data: { 9 | lists: { 10 | AlbumName: string; 11 | SingerName: string; 12 | SongName: string; 13 | Suffix: string; 14 | FileHash: string; 15 | ExtName: string; 16 | HQFileHash: string; 17 | HQExtName: string; 18 | SQFileHash: string; 19 | SQExtName: string; 20 | SuperFileHash: string; 21 | SuperExtName: string; 22 | }[]; 23 | }; 24 | } 25 | 26 | // const format = MUSIC_QUALITY === 999000 ? 0 : MUSIC_QUALITY === 320000 ? 1 : 2; 27 | 28 | async function search(keyword: string) { 29 | keyword = encodeURIComponent(keyword); 30 | 31 | try { 32 | const { 33 | data: { 34 | data: { lists }, 35 | }, 36 | } = await axios.get( 37 | `http://songsearch.kugou.com/song_search_v2?keyword=${keyword}&page=1&pagesize=8&platform=WebFilter` 38 | ); 39 | return lists.map( 40 | ({ 41 | AlbumName, 42 | SingerName, 43 | SongName, 44 | FileHash, 45 | // ExtName, 46 | // HQFileHash, 47 | // HQExtName, 48 | // SQFileHash, 49 | // SQExtName, 50 | }) => ({ 51 | album: AlbumName, 52 | artist: SingerName.split("、"), 53 | dt: 0, 54 | id: FileHash, 55 | name: SongName, 56 | // ...[ 57 | // { hash: SQFileHash, type: SQExtName }, 58 | // { hash: HQFileHash, type: HQExtName }, 59 | // { hash: FileHash, type: ExtName }, 60 | // ] 61 | // .slice(format) 62 | // .filter(({ hash }) => hash)[0], 63 | }) 64 | ); 65 | } catch {} 66 | return []; 67 | } 68 | 69 | async function songUrl({ id }: UnlockSongItem) { 70 | try { 71 | const { 72 | data: { url }, 73 | } = await axios.get<{ url: string[] }>( 74 | `http://trackercdn.kugou.com/i/v2/?key=${createHash("md5") 75 | .update(`${id}kgcloudv2`) 76 | .digest("hex")}&hash=${id}&pid=2&cmd=25&behavior=play` 77 | ); 78 | return { url: url[0], type: extname(url[0]).split(".").pop(), md5: id }; 79 | } catch {} 80 | return; 81 | } 82 | 83 | export default async function kugou( 84 | song: SongsItem 85 | ): Promise { 86 | const list = await search(song.name); 87 | const selected = filter(list, song); 88 | return selected ? await songUrl(selected) : undefined; 89 | } 90 | */ 91 | 92 | export default undefined; 93 | -------------------------------------------------------------------------------- /packages/client/src/unblock/kuwo.ts: -------------------------------------------------------------------------------- 1 | /* import type { SongDetail, SongsItem, UnlockSongItem } from "../constant"; 2 | import { MUSIC_QUALITY } from "../constant"; 3 | import axios from "axios"; 4 | import { extname } from "node:path"; 5 | import filter from "./filter"; 6 | 7 | let crypt: undefined | ((_: string) => Uint8Array); 8 | 9 | import("../../../wasi") 10 | // eslint-disable-next-line @typescript-eslint/naming-convention 11 | .then(({ kuwo_crypt }) => (crypt = kuwo_crypt)) 12 | .catch(console.error); 13 | 14 | interface SearchResult { 15 | data: { 16 | list: { 17 | album: string; 18 | artist: string; 19 | musicrid: string; 20 | name: string; 21 | duration: string; 22 | }[]; 23 | }; 24 | } 25 | 26 | async function search(keyword: string) { 27 | keyword = encodeURIComponent(keyword); 28 | 29 | try { */ 30 | // const token = 31 | // ((await axios.get(`http://kuwo.cn/search/list?key=${keyword}`)) 32 | // .headers as { "set-cookie": string[] })["set-cookie"] 33 | // .find((line: string) => line.includes("kw_token")) 34 | // ?.replace(/;.*/, "") 35 | // .split("=") 36 | // .pop() || ""; 37 | 38 | /* const token = "WIRAHDP0MZ"; 39 | const { 40 | data: { 41 | data: { list }, 42 | }, 43 | } = await axios.get( 44 | `http://www.kuwo.cn/api/www/search/searchMusicBykeyWord?key=${keyword}&pn=1&rn=8`, 45 | { 46 | headers: { 47 | referer: `http://www.kuwo.cn/search/list?key=${keyword}`, 48 | csrf: token, 49 | cookie: `kw_token=${token}`, 50 | }, 51 | } 52 | ); 53 | return list.map(({ album, artist, musicrid, name, duration }) => { 54 | const dt = duration.split(":").map((i) => parseInt(i)); 55 | return { 56 | album, 57 | artist: artist.split("&"), 58 | dt: (dt[0] * 60 + dt[1]) * 1000, 59 | id: musicrid, 60 | name, 61 | }; 62 | }); 63 | } catch {} 64 | return []; 65 | } 66 | 67 | const format = ["flac", "mp3"] 68 | .slice(MUSIC_QUALITY === 999000 ? 0 : 1) 69 | .join("|"); 70 | 71 | async function songUrl({ id }: UnlockSongItem) { 72 | if (!crypt) return; 73 | try { 74 | if (MUSIC_QUALITY > 128000) { 75 | id = id.split("_").pop() || ""; 76 | const { data } = await axios.get( 77 | `http://mobi.kuwo.cn/mobi.s?f=kuwo&q=${Buffer.from( 78 | crypt( 79 | `corp=kuwo&p2p=1&type=convert_url2&sig=0&format=${format}&rid=${id}` 80 | ) 81 | ).toString("base64")}`, 82 | { headers: { "user-agent": "okhttp/3.10.0" } } 83 | ); 84 | const obj: Record = {}; 85 | data.split("\r\n").forEach((line) => { 86 | const [key, value] = line.split("="); 87 | obj[key] = value; 88 | }); 89 | return { url: obj["url"], type: obj["format"] }; 90 | } else { 91 | const { data } = await axios.get( 92 | `http://antiserver.kuwo.cn/anti.s?type=convert_url&format=mp3&response=url&rid=${id}`, 93 | { headers: { "user-agent": "okhttp/3.10.0" } } 94 | ); 95 | return { 96 | url: data, 97 | type: extname(data).split(".").pop(), 98 | }; 99 | } 100 | } catch {} 101 | return; 102 | } 103 | 104 | export default async function kuwo( 105 | song: SongsItem 106 | ): Promise { 107 | const list = await search(song.name); 108 | const selected = filter(list, song); 109 | return selected ? await songUrl(selected) : undefined; 110 | } 111 | */ 112 | 113 | export default undefined; 114 | -------------------------------------------------------------------------------- /packages/client/src/unblock/migu.ts: -------------------------------------------------------------------------------- 1 | /* import type { SongDetail, SongsItem, UnlockSongItem } from "../constant"; 2 | import { 3 | constants, 4 | createCipheriv, 5 | createHash, 6 | publicEncrypt, 7 | randomBytes, 8 | } from "node:crypto"; 9 | import { MUSIC_QUALITY } from "../constant"; 10 | import axios from "axios"; 11 | import { extname } from "node:path"; 12 | import filter from "./filter"; 13 | import { stringify } from "node:querystring"; 14 | 15 | interface SearchResult { 16 | musics: { 17 | albumName: string; 18 | artist: string; 19 | singerName: string; 20 | copyrightId: string; 21 | title: string; 22 | songName: string; 23 | mp3: string; 24 | }[]; 25 | } 26 | 27 | async function search(keyword: string) { 28 | keyword = encodeURIComponent(keyword); 29 | 30 | try { 31 | const { 32 | data: { musics }, 33 | } = await axios.get( 34 | `http://m.music.migu.cn/migu/remoting/scr_search_tag?keyword=${keyword}&type=2&rows=20&pgc=1`, 35 | { 36 | headers: { 37 | referer: `http://m.music.migu.cn/migu/remoting/scr_search_tag?keyword=${keyword}`, 38 | }, 39 | } 40 | ); 41 | return musics.map(({ albumName, singerName, copyrightId, title, mp3 }) => ({ 42 | album: albumName, 43 | artist: singerName.split(", "), 44 | dt: 0, 45 | id: copyrightId, 46 | name: title, 47 | mp3, 48 | })); 49 | } catch {} 50 | return []; 51 | } 52 | 53 | const key = 54 | "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8asrfSaoOb4je+DSmKdriQJKWVJ2oDZrs3wi5W67m3LwTB9QVR+cE3XWU21Nx+YBxS0yun8wDcjgQvYt625ZCcgin2ro/eOkNyUOTBIbuj9CvMnhUYiR61lC1f1IGbrSYYimqBVSjpifVufxtx/I3exReZosTByYp4Xwpb1+WAQIDAQAB\n-----END PUBLIC KEY-----"; 55 | 56 | function encrypt(data: Record) { 57 | const password = Buffer.from(randomBytes(32).toString("hex")); 58 | const salt = randomBytes(8); 59 | 60 | const result: Buffer[] = [Buffer.alloc(0)]; 61 | for (let i = 0; i < 5; ++i) { 62 | result.push( 63 | createHash("md5") 64 | .update(Buffer.concat([result[result.length - 1], password, salt])) 65 | .digest() 66 | ); 67 | } 68 | const buffer = Buffer.concat(result); 69 | const cipher = createCipheriv( 70 | "aes-256-cbc", 71 | buffer.slice(0, 32), 72 | buffer.slice(32, 48) 73 | ); 74 | 75 | return stringify({ 76 | data: Buffer.concat([ 77 | Buffer.from("Salted__"), 78 | salt, 79 | cipher.update(Buffer.from(JSON.stringify(data))), 80 | cipher.final(), 81 | ]).toString("base64"), 82 | secKey: publicEncrypt( 83 | { key, padding: constants.RSA_PKCS1_PADDING }, 84 | password 85 | ).toString("base64"), 86 | }); 87 | } 88 | 89 | const format = MUSIC_QUALITY === 999000 ? 3 : MUSIC_QUALITY === 320000 ? 2 : 1; 90 | 91 | async function songUrl({ id, mp3 }: UnlockSongItem & { mp3?: string }) { 92 | if (MUSIC_QUALITY === 128000 && mp3) { 93 | return { 94 | url: mp3, 95 | type: extname(mp3).split(".").pop(), 96 | }; 97 | } 98 | try { 99 | const { 100 | data: { 101 | data: { playUrl }, 102 | }, 103 | } = await axios.get<{ data: { playUrl: string } }>( 104 | `http://music.migu.cn/v3/api/music/audioPlayer/getPlayInfo?dataType=2&${encrypt( 105 | { copyrightId: id, type: format } 106 | )}`, 107 | { 108 | headers: { 109 | origin: "http://music.migu.cn/", 110 | referer: "http://music.migu.cn/", 111 | }, 112 | } 113 | ); 114 | return playUrl 115 | ? { 116 | url: `http:${encodeURI(playUrl)}`, 117 | type: extname(playUrl.split("?").shift() || "") 118 | .split(".") 119 | .pop(), 120 | } 121 | : undefined; 122 | } catch {} 123 | return; 124 | } 125 | 126 | export default async function migu( 127 | song: SongsItem 128 | ): Promise { 129 | const list = await search(song.name); 130 | const selected = filter(list, song); 131 | return selected ? await songUrl(selected) : undefined; 132 | } 133 | */ 134 | 135 | export default undefined; 136 | -------------------------------------------------------------------------------- /packages/client/src/unblock/qq.ts: -------------------------------------------------------------------------------- 1 | export default undefined; 2 | 3 | /* import type { SongsItem } from "../constant"; 4 | import axios from "axios"; 5 | import { userAgent } from "../api"; 6 | 7 | interface SearchResult { 8 | data: { 9 | song: { 10 | list: { 11 | mid: string; 12 | name: string; 13 | album: { name: string }; 14 | file: { 15 | // eslint-disable-next-line @typescript-eslint/naming-convention 16 | media_mid: string; 17 | }; 18 | singer: { name: string }[]; 19 | }[]; 20 | }; 21 | }; 22 | } 23 | 24 | async function search(keyword: string) { 25 | const url = `https://c.y.qq.com/soso/fcgi-bin/client_search_cp?format=json&new_json=1&w=${encodeURIComponent( 26 | keyword 27 | )}`; 28 | 29 | try { 30 | const res = await axios.get(url, { 31 | headers: { 32 | // eslint-disable-next-line @typescript-eslint/naming-convention 33 | "User-Agent": userAgent, 34 | }, 35 | }); 36 | console.error(res); 37 | const { 38 | data: { 39 | data: { 40 | song: { list }, 41 | }, 42 | }, 43 | } = res; 44 | return list; 45 | } catch {} 46 | return []; 47 | } 48 | 49 | interface SongUrlResult { 50 | // eslint-disable-next-line @typescript-eslint/naming-convention 51 | req_0: { 52 | data: { 53 | sip: string[]; 54 | midurlinfo: { purl: string }[]; 55 | }; 56 | }; 57 | } 58 | 59 | async function songUrl(mid: string) { 60 | const url = 61 | "https://u.y.qq.com/cgi-bin/musicu.fcg?data=" + 62 | encodeURIComponent( 63 | JSON.stringify({ 64 | // eslint-disable-next-line @typescript-eslint/naming-convention 65 | req_0: { 66 | module: "vkey.GetVkeyServer", 67 | method: "CgiGetVkey", 68 | param: { 69 | guid: `${Math.floor(Math.random() * 1000000000)}`, 70 | loginflag: 1, 71 | songmid: [mid], 72 | songtype: [0], 73 | uin: "0", 74 | platform: "20", 75 | }, 76 | }, 77 | }) 78 | ); 79 | 80 | try { 81 | const xx = await axios.get(url, { 82 | headers: { 83 | origin: "http://y.qq.com/", 84 | referer: "http://y.qq.com/", 85 | }, 86 | }); 87 | 88 | const { 89 | data: { 90 | req_0: { 91 | data: { sip, midurlinfo }, 92 | }, 93 | }, 94 | } = xx; 95 | 96 | return `${sip[0]}${midurlinfo[0].purl}`; 97 | } catch {} 98 | return; 99 | } 100 | 101 | export async function qq(song: SongsItem) { 102 | const list = await search(song.name); 103 | const url = await songUrl(list[0].mid); 104 | } 105 | */ 106 | -------------------------------------------------------------------------------- /packages/client/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ipc.js"; 2 | export * from "./multiStepInput.js"; 3 | export * from "./search.js"; 4 | export * from "./state.js"; 5 | export * from "./util.js"; 6 | export * from "./webview.js"; 7 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2022"], 5 | "module": "node16", 6 | "moduleResolution": "node16", 7 | "noEmit": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitAny": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | "target": "ES2022" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudmusic/server", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "./src/index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "check": "tsc -p tsconfig.json", 9 | "lint": "eslint src/**" 10 | }, 11 | "devDependencies": { 12 | "@types/api": "workspace:*", 13 | "@types/global-agent": "2.1.3", 14 | "@types/node": "*", 15 | "@types/tough-cookie": "4.0.5", 16 | "@types/yallist": "4.0.4" 17 | }, 18 | "dependencies": { 19 | "@cloudmusic/shared": "workspace:*", 20 | "global-agent": "3.0.0", 21 | "got": "14.3.0", 22 | "md5-file": "5.0.0", 23 | "node-cache": "5.1.2", 24 | "tough-cookie": "4.1.4", 25 | "yallist": "5.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/src/api/helper.ts: -------------------------------------------------------------------------------- 1 | export const API_CONFIG = { protocol: process.env["CM_HTTPS_API"] === "0" ? "http" : "https" }; 2 | -------------------------------------------------------------------------------- /packages/server/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * as NeteaseAPI from "./netease/index.js"; 2 | -------------------------------------------------------------------------------- /packages/server/src/api/netease/album.ts: -------------------------------------------------------------------------------- 1 | import { resolveAlbumsItem, resolveSongItem } from "./helper.js"; 2 | import { API_CACHE } from "../../cache.js"; 3 | import type { NeteaseTypings } from "api"; 4 | import { weapiRequest } from "./request.js"; 5 | 6 | type AlbumRet = { 7 | readonly album: NeteaseTypings.AlbumsItem; 8 | readonly songs: readonly NeteaseTypings.SongsItem[]; 9 | }; 10 | 11 | export async function album(id: number): Promise { 12 | const key = `album${id}`; 13 | const value = API_CACHE.get(key); 14 | if (value) return value; 15 | const res = await weapiRequest(`music.163.com/weapi/v1/album/${id}`); 16 | if (!res) return { album: {}, songs: [] }; 17 | const { album, songs } = res; 18 | const ret = { album: resolveAlbumsItem(album), songs: songs.map(resolveSongItem) }; 19 | API_CACHE.set(key, ret); 20 | return ret; 21 | } 22 | 23 | export async function albumNewest(): Promise { 24 | const key = "album_newest"; 25 | const value = API_CACHE.get(key); 26 | if (value) return value; 27 | const res = await weapiRequest<{ 28 | albums: readonly NeteaseTypings.AlbumsItem[]; 29 | }>("music.163.com/weapi/discovery/newAlbum"); 30 | if (!res) return []; 31 | const ret = res.albums.map(resolveAlbumsItem); 32 | API_CACHE.set(key, ret); 33 | return ret; 34 | } 35 | 36 | export async function albumSub(id: number, t: "sub" | "unsub"): Promise { 37 | return !!(await weapiRequest(`music.163.com/weapi/album/${t}`, { id })); 38 | } 39 | 40 | export async function albumSublist(): Promise { 41 | const limit = 100; 42 | let offset = 0; 43 | const ret: NeteaseTypings.AlbumsItem[] = []; 44 | for (let i = 0; i < 16; ++i) { 45 | const res = await weapiRequest<{ 46 | data: readonly NeteaseTypings.AlbumsItem[]; 47 | }>("music.163.com/weapi/album/sublist", { limit, offset, total: true }); 48 | if (!res) return []; 49 | ret.push(...res.data.map(resolveAlbumsItem)); 50 | if (res.data.length < limit) break; 51 | offset += limit; 52 | } 53 | return ret; 54 | } 55 | 56 | export async function topAlbum(): Promise { 57 | const key = "top_album"; 58 | const value = API_CACHE.get(key); 59 | if (value) return value; 60 | const date = new Date(); 61 | const res = await weapiRequest<{ 62 | monthData: readonly NeteaseTypings.AlbumsItem[]; 63 | }>("music.163.com/weapi/discovery/new/albums/area", { 64 | area: "ALL", // //ALL:全部,ZH:华语,EA:欧美,KR:韩国,JP:日本 65 | limit: 50, 66 | offset: 0, 67 | type: "new", 68 | year: date.getFullYear(), 69 | month: date.getMonth() + 1, 70 | total: false, 71 | rcmd: true, 72 | }); 73 | if (!res) return []; 74 | const ret = res.monthData.map(resolveAlbumsItem); 75 | API_CACHE.set(key, ret); 76 | return ret; 77 | } 78 | -------------------------------------------------------------------------------- /packages/server/src/api/netease/comment.ts: -------------------------------------------------------------------------------- 1 | import { eapiRequest, weapiRequest } from "./request.js"; 2 | import type { NeteaseCommentType } from "@cloudmusic/shared"; 3 | import { NeteaseSortType } from "@cloudmusic/shared"; 4 | import type { NeteaseTypings } from "api"; 5 | import { resolveComment } from "./helper.js"; 6 | 7 | const resourceTypeMap = ["R_SO_4_", "R_MV_5_", "A_PL_0_", "R_AL_3_", "A_DJ_1_", "R_VI_62_", "A_EV_2_", "A_DR_14_"]; 8 | 9 | export async function commentAdd(type: NeteaseCommentType, id: number, content: string): Promise { 10 | return !!(await weapiRequest(`music.163.com/weapi/resource/comments/add`, { 11 | threadId: `${resourceTypeMap[type]}${id}`, 12 | content, 13 | })); 14 | } 15 | 16 | export async function commentReply( 17 | type: NeteaseCommentType, 18 | id: number, 19 | content: string, 20 | commentId: number, 21 | ): Promise { 22 | return !!(await weapiRequest(`music.163.com/weapi/resource/comments/reply`, { 23 | threadId: `${resourceTypeMap[type]}${id}`, 24 | content, 25 | commentId, 26 | })); 27 | } 28 | 29 | export async function commentFloor( 30 | type: NeteaseCommentType, 31 | id: number, 32 | parentCommentId: number, 33 | limit: number, 34 | time: number, 35 | ): Promise { 36 | const res = await weapiRequest<{ 37 | data: { totalCount: number; hasMore: boolean; comments: readonly NeteaseTypings.RawCommentDetail[] }; 38 | }>("music.163.com/weapi/resource/comment/floor/get", { 39 | parentCommentId, 40 | threadId: `${resourceTypeMap[type]}${id}`, 41 | time, 42 | limit, 43 | }); 44 | if (!res) return { totalCount: 0, hasMore: false, comments: [] }; 45 | const { 46 | data: { totalCount, hasMore, comments }, 47 | } = res; 48 | return { totalCount, hasMore, comments: comments.map(resolveComment) }; 49 | } 50 | 51 | export async function commentLike( 52 | type: NeteaseCommentType, 53 | t: "like" | "unlike", 54 | id: number, 55 | commentId: number, 56 | ): Promise { 57 | return !!(await weapiRequest(`music.163.com/weapi/v1/comment/${t}`, { 58 | threadId: `${resourceTypeMap[type]}${id}`, 59 | commentId, 60 | })); 61 | } 62 | 63 | export async function commentNew( 64 | type: NeteaseCommentType, 65 | id: number, 66 | pageNo: number, 67 | pageSize: number, 68 | sortType: NeteaseSortType, 69 | cursor: number | string, 70 | ): Promise { 71 | switch (sortType) { 72 | case NeteaseSortType.recommendation: 73 | cursor = (pageNo - 1) * pageSize; 74 | break; 75 | case NeteaseSortType.hottest: 76 | cursor = `normalHot#${(pageNo - 1) * pageSize}`; 77 | break; 78 | } 79 | const res = await eapiRequest<{ 80 | data: { totalCount: number; hasMore: boolean; comments: readonly NeteaseTypings.RawCommentDetail[] }; 81 | }>( 82 | "music.163.com/eapi/v2/resource/comments", 83 | { threadId: `${resourceTypeMap[type]}${id}`, pageNo, showInner: true, pageSize, cursor, sortType }, 84 | "/api/v2/resource/comments", 85 | ); 86 | if (!res) return { totalCount: 0, hasMore: false, comments: [] }; 87 | const { 88 | data: { totalCount, hasMore, comments }, 89 | } = res; 90 | return { totalCount, hasMore, comments: comments.map(resolveComment) }; 91 | } 92 | -------------------------------------------------------------------------------- /packages/server/src/api/netease/crypto.ts: -------------------------------------------------------------------------------- 1 | import { constants, createCipheriv, createHash, publicEncrypt, randomBytes } from "node:crypto"; 2 | 3 | const iv = Buffer.from("0102030405060708"); 4 | const presetKey = Buffer.from("0CoJUm6Qyw8W8jud"); 5 | const base62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 6 | const publicKey = 7 | "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----"; 8 | const eapiKey = "e82ckenh8dichen8"; 9 | 10 | const aesEncrypt = (buffer: Buffer, mode: string, key: Uint8Array | Buffer | string, iv: Buffer | string) => { 11 | const cipher = createCipheriv(`aes-128-${mode}`, key, iv); 12 | return Buffer.concat([cipher.update(buffer), cipher.final()]); 13 | }; 14 | 15 | const rsaEncrypt = (buffer: Uint8Array) => 16 | publicEncrypt( 17 | { key: publicKey, padding: constants.RSA_NO_PADDING }, 18 | Buffer.concat([Buffer.alloc(128 - buffer.length), buffer]), 19 | ); 20 | 21 | export const weapi = (object: Record): { params: string; encSecKey: string } => { 22 | const text = JSON.stringify(object); 23 | const secretKey = randomBytes(16).map((n) => base62.charAt(n % 62).charCodeAt(0)); 24 | return { 25 | params: aesEncrypt( 26 | Buffer.from(aesEncrypt(Buffer.from(text), "cbc", presetKey, iv).toString("base64")), 27 | "cbc", 28 | secretKey, 29 | iv, 30 | ).toString("base64"), 31 | encSecKey: rsaEncrypt(secretKey.reverse()).toString("hex"), 32 | }; 33 | }; 34 | 35 | export const eapi = (url: string, object: Record): { params: string } => { 36 | const text = JSON.stringify(object); 37 | const message = `nobody${url}use${text}md5forencrypt`; 38 | const digest = createHash("md5").update(message).digest("hex"); 39 | const data = `${url}-36cd479b6b5-${text}-36cd479b6b5-${digest}`; 40 | return { params: aesEncrypt(Buffer.from(data), "ecb", eapiKey, "").toString("hex").toUpperCase() }; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/server/src/api/netease/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./account.js"; 2 | export * from "./album.js"; 3 | export * from "./artist.js"; 4 | export * from "./comment.js"; 5 | export * from "./djradio.js"; 6 | export * from "./mv.js"; 7 | export * from "./playlist.js"; 8 | export * from "./search.js"; 9 | export * from "./song.js"; 10 | -------------------------------------------------------------------------------- /packages/server/src/api/netease/mv.ts: -------------------------------------------------------------------------------- 1 | import { API_CACHE } from "../../cache.js"; 2 | import type { NeteaseTypings } from "api"; 3 | import { resolveMvDetail } from "./helper.js"; 4 | import { weapiRequest } from "./request.js"; 5 | 6 | export async function mvDetail(id: number) { 7 | const key = `mv_detail${id}`; 8 | const value = API_CACHE.get(key); 9 | if (value) return value; 10 | 11 | const res = await weapiRequest<{ data: NeteaseTypings.MvDetail }>("music.163.com/weapi/v1/mv/detail", { id }); 12 | if (!res || !res.data.brs.length) return; 13 | const ret = resolveMvDetail(res.data); 14 | API_CACHE.set(key, ret); 15 | return ret; 16 | } 17 | 18 | export async function mvUrl(id: number, r = 1080) { 19 | const res = await weapiRequest<{ data: { url: string; r: number; size: number; md5: string } }>( 20 | "music.163.com/weapi/song/enhance/play/mv/url", 21 | { id, r }, 22 | ); 23 | if (!res) return; 24 | return res.data.url; 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/src/cache.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_DIR, LYRIC_CACHE_DIR, MUSIC_CACHE_DIR } from "./constant.js"; 2 | import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; 3 | import type { NeteaseTypings } from "api"; 4 | import type { Node } from "yallist"; 5 | import NodeCache from "node-cache"; 6 | import { STATE } from "./state.js"; 7 | import { Yallist } from "yallist"; 8 | import { logError } from "./utils.js"; 9 | import md5File from "md5-file"; 10 | import { resolve } from "node:path"; 11 | 12 | export const API_CACHE = new NodeCache({ 13 | stdTTL: 300, 14 | checkperiod: 600, 15 | useClones: false, 16 | deleteOnExpire: true, 17 | enableLegacyCallbacks: false, 18 | maxKeys: -1, 19 | }); 20 | 21 | type LyricCacheItem = NeteaseTypings.LyricData & { ctime: number }; 22 | 23 | class LyricCache { 24 | clear(): void { 25 | rm(LYRIC_CACHE_DIR, { recursive: true }) 26 | .catch(() => undefined) 27 | .then(() => mkdir(LYRIC_CACHE_DIR, { recursive: true })) 28 | .catch(() => undefined); 29 | } 30 | 31 | async get(key: string): Promise { 32 | try { 33 | const path = resolve(LYRIC_CACHE_DIR, key); 34 | const data = JSON.parse((await readFile(path)).toString()); 35 | // 7 * 24 * 60 * 60 * 1000 36 | if (Date.now() - data.ctime < 604800000) return data; 37 | rm(path, { recursive: true, force: true }).catch(() => undefined); 38 | } catch {} 39 | return; 40 | } 41 | 42 | put(key: string, data: LyricCacheItem): void { 43 | writeFile(resolve(LYRIC_CACHE_DIR, key), Buffer.from(JSON.stringify(data), "utf8")).catch(() => undefined); 44 | } 45 | } 46 | 47 | export const LYRIC_CACHE = new LyricCache(); 48 | 49 | type MusicCacheNode = { 50 | name: string; 51 | key: string; 52 | size: number; 53 | }; 54 | 55 | class MusicCache { 56 | #size = 0; 57 | 58 | readonly #list = new Yallist(); 59 | 60 | readonly #cache = new Map>(); 61 | 62 | readonly #listPath = resolve(CACHE_DIR, "music-list"); 63 | 64 | async init(): Promise { 65 | const names = new Set( 66 | (await readdir(MUSIC_CACHE_DIR, { withFileTypes: true })).filter((i) => i.isFile()).map(({ name }) => name), 67 | ); 68 | 69 | try { 70 | const list = JSON.parse((await readFile(this.#listPath)).toString()); 71 | 72 | list 73 | .filter(({ name }) => names.has(name)) 74 | .reverse() 75 | .forEach((value) => { 76 | names.delete(value.name); 77 | this.#addNode(value); 78 | }); 79 | } catch {} 80 | this.store().catch(logError); 81 | 82 | for (const name of names) { 83 | const path = resolve(MUSIC_CACHE_DIR, name); 84 | rm(path, { recursive: true, force: true }).catch(logError); 85 | } 86 | } 87 | 88 | clear(): void { 89 | rm(MUSIC_CACHE_DIR, { recursive: true }) 90 | .catch(() => undefined) 91 | .then(() => mkdir(MUSIC_CACHE_DIR, { recursive: true })) 92 | .catch(() => undefined); 93 | this.#cache.clear(); 94 | this.#size = 0; 95 | while (this.#list.length) this.#list.pop(); 96 | this.store().catch(logError); 97 | } 98 | 99 | store(): Promise { 100 | const json = JSON.stringify(this.#list.toArray()); 101 | return writeFile(this.#listPath, json); 102 | } 103 | 104 | get(key: string): string | void { 105 | const node = this.#cache.get(key); 106 | if (node) { 107 | this.#list.unshiftNode(node); 108 | return resolve(MUSIC_CACHE_DIR, node.value.name); 109 | } 110 | } 111 | 112 | async put(key: string, name: string, path: string, md5?: string): Promise { 113 | try { 114 | if (!md5 || (await md5File(path)) === md5) { 115 | const target = resolve(MUSIC_CACHE_DIR, name); 116 | await copyFile(path, target); 117 | const { size } = await stat(target); 118 | this.#deleteNode({ key, name }); 119 | this.#addNode({ key, name, size }); 120 | return target; 121 | } 122 | } catch {} 123 | } 124 | 125 | #addNode(value: MusicCacheNode) { 126 | this.#list.unshift(value); 127 | if (!this.#list.head) return; 128 | this.#cache.set(value.key, this.#list.head); 129 | this.#size += value.size; 130 | while (this.#size > STATE.cacheSize) { 131 | const { tail } = this.#list; 132 | if (tail) this.#deleteNode(tail.value); 133 | else void this.clear(); 134 | } 135 | } 136 | 137 | #deleteNode({ key, name }: { key: string; name: string }) { 138 | const node = this.#cache.get(key); 139 | if (node) { 140 | this.#list.removeNode(node); 141 | this.#cache.delete(key); 142 | this.#size -= node.value.size; 143 | rm(resolve(MUSIC_CACHE_DIR, name), { recursive: true, force: true }).catch(logError); 144 | } 145 | } 146 | } 147 | 148 | export const MUSIC_CACHE = new MusicCache(); 149 | -------------------------------------------------------------------------------- /packages/server/src/constant.ts: -------------------------------------------------------------------------------- 1 | import { ipcAppspace, ipcBroadcastServerId, ipcServerId } from "@cloudmusic/shared"; 2 | import { homedir } from "node:os"; 3 | import { resolve } from "node:path"; 4 | 5 | export const ipcServerPath = 6 | process.platform === "win32" ? `\\\\.\\pipe\\tmp-${ipcAppspace}${ipcServerId}` : `/tmp/${ipcAppspace}${ipcServerId}`; 7 | 8 | export const ipcBroadcastServerPath = 9 | process.platform === "win32" 10 | ? `\\\\.\\pipe\\tmp-${ipcAppspace}${ipcBroadcastServerId}` 11 | : `/tmp/${ipcAppspace}${ipcBroadcastServerId}`; 12 | 13 | export const SETTING_DIR = process.env["CM_SETTING_DIR"] || resolve(homedir(), ".cloudmusic"); 14 | export const TMP_DIR = resolve(SETTING_DIR, "tmp"); 15 | export const CACHE_DIR = resolve(SETTING_DIR, "cache"); 16 | export const MUSIC_CACHE_DIR = resolve(CACHE_DIR, "music"); 17 | export const LYRIC_CACHE_DIR = resolve(CACHE_DIR, "lyric"); 18 | 19 | export const RETAIN_FILE = resolve(SETTING_DIR, "retain"); 20 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_DIR, LYRIC_CACHE_DIR, MUSIC_CACHE_DIR, TMP_DIR } from "./constant.js"; 2 | import type { CSMessage, IPCApi, IPCMsg } from "@cloudmusic/shared"; 3 | import { mkdir, readdir, rm, stat } from "node:fs/promises"; 4 | import { MUSIC_CACHE } from "./cache.js"; 5 | import type { NeteaseAPI } from "./api/index.js"; 6 | import { bootstrap } from "global-agent"; 7 | import http from "node:http"; 8 | import https from "node:https"; 9 | import { logError } from "./utils.js"; 10 | import { resolve } from "node:path"; 11 | 12 | export type NeteaseAPIKey = keyof typeof NeteaseAPI; 13 | 14 | export type NeteaseAPIParameters = Parameters<(typeof NeteaseAPI)[T]>; 15 | 16 | export type NeteaseAPIReturn = 17 | ReturnType<(typeof NeteaseAPI)[T]> extends PromiseLike ? U : ReturnType<(typeof NeteaseAPI)[T]>; 18 | 19 | export type NeteaseAPICMsg = IPCMsg< 20 | IPCApi.netease, 21 | CSMessage<{ i: T; p: NeteaseAPIParameters }> 22 | >; 23 | 24 | export type NeteaseAPISMsg = IPCMsg>>; 25 | 26 | process.on("unhandledRejection", logError); 27 | process.on("uncaughtException", logError); 28 | if (process.env.GLOBAL_AGENT_HTTP_PROXY) bootstrap(); 29 | else { 30 | const agentOptions = { keepAlive: true, maxSockets: 32 }; 31 | http.globalAgent = new http.Agent(agentOptions); 32 | https.globalAgent = new https.Agent(agentOptions); 33 | } 34 | 35 | await Promise.allSettled([mkdir(TMP_DIR), mkdir(CACHE_DIR)]); 36 | await Promise.allSettled([mkdir(LYRIC_CACHE_DIR), mkdir(MUSIC_CACHE_DIR)]); 37 | await MUSIC_CACHE.init(); 38 | 39 | { 40 | const exp = Date.now() - 86400000; // 1 day 41 | const names = await readdir(TMP_DIR); 42 | await Promise.allSettled( 43 | names.map(async (name) => { 44 | const path = resolve(TMP_DIR, name); 45 | const { birthtimeMs } = await stat(path); 46 | if (exp >= birthtimeMs) return rm(path, { force: true }); 47 | }), 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /packages/server/src/state.ts: -------------------------------------------------------------------------------- 1 | import type { NeteaseTypings } from "api"; 2 | 3 | export const STATE = { 4 | foreign: process.env["CM_FOREIGN"] === "1", 5 | minSize: 256 * 1024, 6 | cacheSize: parseInt(process.env["CM_MUSIC_CACHE_SIZE"]), 7 | musicQuality: parseInt(process.env["CM_MUSIC_QUALITY"]), 8 | lyric: { 9 | delay: -1.0, 10 | idx: 0, 11 | time: [0], 12 | text: [["~", "~", "~"]], 13 | user: [], 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /packages/server/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { MUSIC_CACHE } from "./cache.js"; 2 | import { STATE } from "./state.js"; 3 | import { TMP_DIR } from "./constant.js"; 4 | import { createWriteStream } from "node:fs"; 5 | import { got } from "got"; 6 | import { resolve } from "node:path"; 7 | import { rm } from "node:fs/promises"; 8 | import { songUrl } from "./api/netease/song.js"; 9 | 10 | export const logError = (err: unknown): void => { 11 | if (err) { 12 | console.error( 13 | new Date().toISOString(), 14 | typeof err === "object" ? (>err)?.stack || (>err)?.message || err : err, 15 | ); 16 | } 17 | }; 18 | 19 | const gotConfig = { isStream: true, http2: true, timeout: { request: 80000 } }; 20 | 21 | export async function getMusicPath(id: number, name: string): Promise { 22 | const idS = `${id}`; 23 | const cachaUrl = MUSIC_CACHE.get(idS); 24 | if (cachaUrl) return cachaUrl; 25 | 26 | const { url, md5 } = await songUrl(id); 27 | if (!url) throw Error(); 28 | const tmpUri = resolve(TMP_DIR, idS); 29 | const download = got(url, gotConfig).once("end", () => void MUSIC_CACHE.put(idS, `${name}-${idS}`, tmpUri, md5)); 30 | 31 | return new Promise((resolve, reject) => { 32 | const file = createWriteStream(tmpUri); 33 | let len = 0; 34 | const onData = ({ length }: { length: number }) => { 35 | len += length; 36 | if (len > STATE.minSize) { 37 | download.removeListener("data", onData); 38 | resolve(tmpUri); 39 | } 40 | }; 41 | download 42 | .once("error", (err) => { 43 | rm(tmpUri, { force: true }).catch(() => undefined); 44 | reject(err); 45 | }) 46 | .on("data", onData) 47 | .pipe(file); 48 | }); 49 | } 50 | 51 | export async function getMusicPathClean(id: number, name: string): Promise { 52 | const idS = `${id}`; 53 | const cachaUrl = MUSIC_CACHE.get(idS); 54 | if (cachaUrl) return cachaUrl; 55 | const { url, md5 } = await songUrl(id); 56 | if (!url) throw Error(); 57 | 58 | const tmpUri = resolve(TMP_DIR, idS); 59 | return new Promise((resolve, reject) => { 60 | const file = createWriteStream(tmpUri); 61 | got(url, gotConfig) 62 | .once("error", (err) => { 63 | rm(tmpUri, { force: true }).catch(() => undefined); 64 | reject(err); 65 | }) 66 | .once( 67 | "end", 68 | () => 69 | void MUSIC_CACHE.put(idS, `${name}-${idS}`, tmpUri, md5).then((target) => { 70 | rm(tmpUri, { force: true }).catch(() => undefined); 71 | target ? resolve(target) : reject(); 72 | }), 73 | ) 74 | .pipe(file); 75 | }); 76 | } 77 | 78 | export function downloadMusic(url: string, path: string) { 79 | try { 80 | const file = createWriteStream(path); 81 | got(url, gotConfig).pipe(file); 82 | } catch {} 83 | } 84 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2022"], 5 | "module": "node16", 6 | "moduleResolution": "node16", 7 | "noEmit": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitAny": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | "target": "ES2022" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudmusic/shared", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "./src/index.ts", 6 | "type": "module", 7 | "devDependencies": { 8 | "@types/api": "workspace:*" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/shared/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./netease"; 2 | -------------------------------------------------------------------------------- /packages/shared/src/api/netease.ts: -------------------------------------------------------------------------------- 1 | export const enum NeteaseArtistArea { 2 | all = "-1", 3 | zh = "7", 4 | ea = "96", 5 | ja = "8", 6 | kr = "16", 7 | other = "0", 8 | } 9 | 10 | export const enum NeteaseArtistType { 11 | male = "1", 12 | female = "2", 13 | band = "3", 14 | } 15 | 16 | export const enum NeteaseCommentType { 17 | song = 0, 18 | mv = 1, 19 | playlist = 2, 20 | album = 3, 21 | dj = 4, 22 | video = 5, 23 | event = 6, 24 | } 25 | 26 | export const enum NeteaseCommentAction { 27 | add = 1, 28 | delete = 0, 29 | reply = 2, 30 | } 31 | 32 | export const enum NeteaseSearchType { 33 | single = 1, 34 | album = 10, 35 | artist = 100, 36 | playlist = 1000, 37 | user = 1002, 38 | mv = 1004, 39 | lyric = 1006, 40 | dj = 1009, 41 | video = 1014, 42 | complex = 1018, 43 | sound = 2000, 44 | } 45 | 46 | export const enum NeteaseSortType { 47 | recommendation = 99, 48 | hottest = 2, 49 | latest = 3, 50 | } 51 | 52 | export const enum NeteaseTopSongType { 53 | all = 0, 54 | zh = 7, 55 | ea = 96, 56 | kr = 16, 57 | ja = 8, 58 | } 59 | -------------------------------------------------------------------------------- /packages/shared/src/constant.ts: -------------------------------------------------------------------------------- 1 | import { version } from "../../../package.json"; 2 | 3 | export const logFile = `err-${version}.log`; 4 | export const ipcAppspace = `cm-vsc-${version}`; 5 | export const ipcServerId = "server"; 6 | export const ipcBroadcastServerId = "bc-server"; 7 | 8 | export const ipcDelimiter = "\f"; 9 | -------------------------------------------------------------------------------- /packages/shared/src/event.ts: -------------------------------------------------------------------------------- 1 | // 0xx 2 | export const enum IPCApi { 3 | netease = "000", 4 | } 5 | 6 | // 1xx 7 | export const enum IPCControl { 8 | deleteCache = "100", 9 | download = "101", 10 | setting = "102", 11 | lyric = "103", 12 | master = "104", 13 | cache = "105", 14 | netease = "106", 15 | new = "107", 16 | retain = "108", 17 | // pid = "109", 18 | } 19 | 20 | // 2xx 21 | export const enum IPCPlayer { 22 | end = "200", 23 | load = "201", 24 | loaded = "202", 25 | lyric = "203", 26 | lyricDelay = "204", 27 | lyricIndex = "205", 28 | pause = "206", 29 | play = "207", 30 | playing = "208", 31 | position = "209", 32 | repeat = "210", 33 | stop = "211", 34 | toggle = "212", 35 | volume = "213", 36 | next = "214", 37 | previous = "215", 38 | speed = "216", 39 | seek = "217", 40 | } 41 | 42 | // 3xx 43 | export const enum IPCQueue { 44 | add = "300", 45 | clear = "301", 46 | delete = "302", 47 | fm = "303", 48 | // fmNext = "304", 49 | play = "305", 50 | new = "306", 51 | shift = "307", 52 | } 53 | 54 | // 4xx 55 | export const enum IPCWasm { 56 | load = "400", 57 | pause = "401", 58 | play = "402", 59 | stop = "403", 60 | volume = "404", 61 | speed = "405", 62 | seek = "406", 63 | } 64 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api/netease.js"; 2 | export * from "./constant.js"; 3 | export * from "./event.js"; 4 | export * from "./net.js"; 5 | export * from "./webview.js"; 6 | -------------------------------------------------------------------------------- /packages/shared/src/net.ts: -------------------------------------------------------------------------------- 1 | import type { IPCControl, IPCPlayer, IPCQueue, IPCWasm } from "./event.js"; 2 | import type { NeteaseTypings } from "api"; 3 | 4 | export type CSMessage = { channel: U; msg?: T; err?: true }; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | export type CSConnPool = Map void; reject: (reason?: string) => void }>; 8 | 9 | export type IPCClientLoadMsg = { 10 | url?: string; 11 | item: NeteaseTypings.SongsItem; 12 | pid?: number; 13 | next?: { id: number; name: string }; 14 | play: boolean; 15 | seek?: number; 16 | }; 17 | 18 | export type IPCMsg> = { t: T } & U; 19 | 20 | export type IPCBroadcastMsg = 21 | | IPCMsg 22 | | IPCMsg 23 | | IPCMsg 24 | | IPCMsg 25 | | IPCMsg 26 | | IPCMsg 27 | | IPCMsg 28 | | IPCMsg 29 | | IPCMsg; 30 | 31 | export type IPCClientMsg = 32 | | IPCMsg 33 | | IPCMsg 34 | | IPCMsg 35 | | IPCMsg 36 | | IPCMsg 37 | | IPCMsg 38 | | IPCMsg 39 | // | IPCMsg 40 | | IPCMsg 41 | | IPCMsg 42 | | IPCMsg 43 | | IPCMsg 44 | | IPCMsg 45 | | IPCMsg 46 | | IPCMsg 47 | | IPCMsg 48 | | IPCMsg 49 | | IPCMsg; 50 | 51 | export type IPCServerMsg = 52 | | IPCMsg 53 | | IPCMsg 54 | | IPCMsg 55 | | IPCMsg 56 | | IPCMsg 57 | | IPCMsg 58 | | IPCMsg 59 | | IPCMsg 60 | | IPCMsg 61 | | IPCMsg 62 | | IPCMsg 63 | | IPCMsg 64 | | IPCMsg 65 | | IPCMsg 66 | | IPCMsg 67 | | IPCMsg 68 | | IPCMsg 69 | | IPCMsg 70 | | IPCMsg 71 | | IPCMsg 72 | | IPCMsg 73 | | IPCMsg 74 | | IPCMsg; 75 | -------------------------------------------------------------------------------- /packages/shared/src/webview.ts: -------------------------------------------------------------------------------- 1 | import type { CSMessage } from "./net.js"; 2 | import type { NeteaseTypings } from "api"; 3 | 4 | export type WebviewType = "comment" | "login" | "description" | "lyric" | "musicRanking" | "video"; 5 | 6 | export type CommentCSMsg = 7 | | { command: "init" } 8 | | { command: "prev" } 9 | | { command: "next" } 10 | | { command: "tabs"; index: number } 11 | | { command: "like"; id: number; t: "unlike" | "like" }; 12 | 13 | export type CommentCMsg = CSMessage<{ command: "user"; id: number }, undefined>; 14 | /* | CSMessage<{ command: "reply"; id: number }, undefined> 15 | | CSMessage<{ command: "floor"; id: number }, undefined> */ 16 | 17 | export type LoginSMsg = { command: "message"; message: string } | { command: "key"; key: string }; 18 | 19 | export type LyricSMsg = 20 | | { command: "lyric"; text: NeteaseTypings.LyricData["text"] } 21 | | { command: "index"; idx: number }; 22 | 23 | export type MusicRankingCMsg = 24 | | CSMessage<{ command: "song"; id: number }, undefined> 25 | | CSMessage<{ command: "album"; id: number }, undefined> 26 | | CSMessage<{ command: "artist"; id: number }, undefined>; 27 | 28 | export type ProviderCMsg = 29 | | { command: "pageLoaded" } 30 | | { command: "toggle" } 31 | | { command: "previous" } 32 | | { command: "next" } 33 | | { command: "account"; userId: number } 34 | | { command: "end" } 35 | | { command: "load"; fail?: true } 36 | | { command: "position"; pos: number } 37 | | { command: "playing"; playing: boolean }; 38 | 39 | export type ProviderSMsg = 40 | | { command: "master"; is: boolean } 41 | | { command: "state"; state: "none" | "paused" | "playing" } 42 | | { 43 | command: "metadata"; 44 | duration?: number; 45 | meta?: { 46 | title?: string; 47 | artist?: string; 48 | album?: string; 49 | artwork?: { src: string; sizes?: string; type?: string }[]; 50 | }; 51 | } 52 | | { command: "account"; profiles: NeteaseTypings.Profile[] } 53 | | { command: "load"; url: string; play: boolean; seek?: number } 54 | | { command: "play" } 55 | | { command: "pause" } 56 | | { command: "stop" } 57 | | { command: "speed"; speed: number } 58 | | { command: "volume"; level: number } 59 | | { command: "seek"; seekOffset: number }; 60 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2022"], 5 | "module": "node16", 6 | "noEmit": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "noImplicitAny": true, 9 | "noImplicitOverride": true, 10 | "noImplicitReturns": true, 11 | "noImplicitThis": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "resolveJsonModule": true, 15 | "strict": true, 16 | "target": "ES2022" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/wasi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudmusic-wasi", 3 | "collaborators": [ 4 | "YXL " 5 | ], 6 | "version": "0.1.0", 7 | "files": [ 8 | "index_bg.wasm", 9 | "index.js", 10 | "index.d.ts" 11 | ], 12 | "module": "index.js", 13 | "types": "index.d.ts", 14 | "sideEffects": false 15 | } 16 | -------------------------------------------------------------------------------- /packages/wasm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudmusic-wasm", 3 | "collaborators": [ 4 | "YXL " 5 | ], 6 | "version": "0.1.0", 7 | "files": [ 8 | "index_bg.wasm", 9 | "index.js", 10 | "index_bg.js", 11 | "index.d.ts" 12 | ], 13 | "module": "index.js", 14 | "types": "index.d.ts", 15 | "sideEffects": [ 16 | "./index.js", 17 | "./snippets/*" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/webview/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudmusic/webview", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "check": "tsc -p tsconfig.json", 7 | "lint": "eslint src/**/*.ts*" 8 | }, 9 | "type": "module", 10 | "devDependencies": { 11 | "@types/api": "workspace:*", 12 | "@types/qrcode": "workspace:*", 13 | "@types/react": "18.3.3", 14 | "@types/react-dom": "18.3.0", 15 | "@types/vscode-webview": "1.57.5" 16 | }, 17 | "dependencies": { 18 | "@cloudmusic/shared": "workspace:*", 19 | "cloudmusic-wasm": "workspace:*", 20 | "dayjs": "1.11.11", 21 | "qrcode": "1.5.3", 22 | "react": "18.3.1", 23 | "react-dom": "18.3.1", 24 | "react-icons": "5.2.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/webview/src/components/comment.tsx: -------------------------------------------------------------------------------- 1 | import type { CommentCMsg, CommentCSMsg } from "@cloudmusic/shared"; 2 | import { request, vscode } from "../utils"; 3 | import { useCallback, useState } from "react"; 4 | import { FiThumbsUp } from "react-icons/fi"; 5 | import type { NeteaseTypings } from "api"; 6 | import dayjs from "dayjs"; 7 | import i18n from "../i18n"; 8 | import relativeTime from "dayjs/plugin/relativeTime"; 9 | 10 | dayjs.extend(relativeTime); 11 | 12 | type CommentProps = NeteaseTypings.CommentDetail; 13 | 14 | // eslint-disable-next-line @typescript-eslint/naming-convention 15 | export const Comment = ({ 16 | user, 17 | content, 18 | commentId, 19 | time, 20 | likedCount, 21 | liked, 22 | replyCount, 23 | beReplied, 24 | }: CommentProps): JSX.Element => { 25 | const [l, setL] = useState(liked); 26 | const likeAction = useCallback(() => { 27 | request({ command: "like", id: commentId, t: l ? "unlike" : "like" }) 28 | .then((res) => { 29 | if (res) setL(!l); 30 | }) 31 | .catch(console.error); 32 | }, [commentId, l]); 33 | 34 | return ( 35 |
36 | {user.nickname} { 41 | const data: Omit = { msg: { command: "user", id: user.userId } }; 42 | vscode.postMessage(data); 43 | }} 44 | /> 45 |
46 |
47 |
{ 50 | const data: Omit = { msg: { command: "user", id: user.userId } }; 51 | vscode.postMessage(data); 52 | }} 53 | > 54 | {user.nickname} 55 |
56 |
{dayjs(time).fromNow()}
57 |
58 |
{content}
59 | {beReplied && ( 60 |
61 |
{ 64 | const data: Omit = { msg: { command: "user", id: beReplied.user.userId } }; 65 | vscode.postMessage(data); 66 | }} 67 | > 68 | @{beReplied.user.nickname} 69 |
70 | : {beReplied.content} 71 |
72 | )} 73 |
74 |
75 |
76 | 77 |
78 |
{likedCount}
79 |
80 |
vscode.postMessage({msg:{ command: "reply", id: commentId }})} 83 | > 84 | {i18n.word.reply} 85 |
86 | {replyCount > 0 && ( 87 |
vscode.postMessage({msg:{ command: "floor", id: commentId }})} 90 | > 91 | {replyCount} {i18n.word.reply} 92 | {" >"} 93 |
94 | )} 95 |
96 |
97 |
98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /packages/webview/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./comment"; 2 | export * from "./musicCard"; 3 | export * from "./tabs"; 4 | -------------------------------------------------------------------------------- /packages/webview/src/components/musicCard.tsx: -------------------------------------------------------------------------------- 1 | import { FiPlayCircle } from "react-icons/fi"; 2 | import type { MusicRankingCMsg } from "@cloudmusic/shared"; 3 | import type { NeteaseTypings } from "api"; 4 | import { vscode } from "../utils"; 5 | 6 | export interface MusicCardProps extends NeteaseTypings.RecordData { 7 | max: number; 8 | } 9 | 10 | // eslint-disable-next-line @typescript-eslint/naming-convention 11 | export const MusicCard = ({ name, id, alia, ar, al, playCount, max }: MusicCardProps): JSX.Element => ( 12 |
13 |
17 | {al.name} { 22 | const data: Omit = { msg: { command: "album", id: al.id } }; 23 | vscode.postMessage(data); 24 | }} 25 | /> 26 |
27 |
{ 30 | const data: Omit = { msg: { command: "song", id } }; 31 | vscode.postMessage(data); 32 | }} 33 | >{`${name}${alia[0] ? ` (${alia.join("/")})` : ""}`}
34 |
35 | {ar.map(({ name, id }, idx) => ( 36 |
{ 40 | const data: Omit = { msg: { command: "artist", id } }; 41 | vscode.postMessage(data); 42 | }} 43 | > 44 | {name} 45 | {idx < ar.length - 1 ? "/" : ""} 46 |
47 | ))} 48 |
49 |
50 | 51 |
{playCount}
52 |
53 | ); 54 | -------------------------------------------------------------------------------- /packages/webview/src/components/tabs.tsx: -------------------------------------------------------------------------------- 1 | export interface TabsProps { 2 | title?: string; 3 | titles: readonly string[]; 4 | selectd: number; 5 | switchTab: (index: number) => void; 6 | } 7 | 8 | // eslint-disable-next-line @typescript-eslint/naming-convention 9 | export const Tabs = ({ title, titles, selectd, switchTab }: TabsProps): JSX.Element => ( 10 | 26 | ); 27 | -------------------------------------------------------------------------------- /packages/webview/src/entries/comment.tsx: -------------------------------------------------------------------------------- 1 | import { request, startEventListener } from "../utils"; 2 | import type { CommentCSMsg } from "@cloudmusic/shared"; 3 | import { CommentList } from "../pages"; 4 | import type { CommentListProps } from "../pages"; 5 | import { createRoot } from "react-dom/client"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 8 | const root = createRoot(document.getElementById("root")!); 9 | 10 | startEventListener(); 11 | 12 | request({ command: "init" }) 13 | .then((props) => root.render()) 14 | .catch(console.error); 15 | -------------------------------------------------------------------------------- /packages/webview/src/entries/description.tsx: -------------------------------------------------------------------------------- 1 | import { request, startEventListener } from "../utils"; 2 | import { Description } from "../pages"; 3 | import type { DescriptionProps } from "../pages"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | const root = createRoot(document.getElementById("root")!); 8 | 9 | startEventListener(); 10 | 11 | request(undefined) 12 | .then((props) => root.render()) 13 | .catch(console.error); 14 | -------------------------------------------------------------------------------- /packages/webview/src/entries/login.tsx: -------------------------------------------------------------------------------- 1 | import { Login } from "../pages"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 5 | const root = createRoot(document.getElementById("root")!); 6 | 7 | root.render(); 8 | -------------------------------------------------------------------------------- /packages/webview/src/entries/lyric.tsx: -------------------------------------------------------------------------------- 1 | import { Lyric } from "../pages"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 5 | const root = createRoot(document.getElementById("root")!); 6 | 7 | root.render(); 8 | -------------------------------------------------------------------------------- /packages/webview/src/entries/musicRanking.tsx: -------------------------------------------------------------------------------- 1 | import { request, startEventListener } from "../utils"; 2 | import { MusicRanking } from "../pages"; 3 | import type { NeteaseTypings } from "api"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | const root = createRoot(document.getElementById("root")!); 8 | 9 | startEventListener(); 10 | 11 | request>(undefined) 12 | .then((record) => 13 | root.render( 14 | i.reduce((pre, { playCount }) => Math.max(pre, playCount), 0) / 100)} 17 | />, 18 | ), 19 | ) 20 | .catch(console.error); 21 | -------------------------------------------------------------------------------- /packages/webview/src/entries/video.tsx: -------------------------------------------------------------------------------- 1 | import { request, startEventListener } from "../utils"; 2 | import { Video } from "../pages"; 3 | import type { VideoProps } from "../pages"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | const root = createRoot(document.getElementById("root")!); 8 | 9 | startEventListener(); 10 | 11 | request(undefined) 12 | .then((props) => root.render(