├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ └── release-it.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .npmrc ├── .prettierrc ├── .release-it.json ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── assets ├── logo.svg └── referrer.json ├── e2e ├── basic.spec.ts └── fixtures.ts ├── eslint.config.js ├── extension ├── _metadata │ └── generated_indexed_rulesets │ │ └── _ruleset1 └── assets │ ├── 128px.png │ ├── 256px.png │ ├── 512px.png │ ├── broken-image.png │ ├── icon-128.png │ ├── icon-512.png │ ├── icon.svg │ └── referrer.json ├── modules.d.ts ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── scripts ├── manifest.ts ├── prepare.ts └── utils.ts ├── shim.d.ts ├── src ├── assets │ ├── 512px.png │ └── logo.svg ├── background │ ├── api │ │ ├── bilibili.ts │ │ └── index.ts │ ├── contentScriptHMR.ts │ ├── font-api │ │ └── index.ts │ ├── main.ts │ ├── msg.define.ts │ └── utils.ts ├── components │ ├── Logo.vue │ ├── README.md │ ├── SharedSubtitle.vue │ ├── __tests__ │ │ └── Logo.test.ts │ ├── dialog │ │ └── index.vue │ ├── drawer │ │ ├── drawer.vue │ │ ├── index.vue │ │ └── style.css │ ├── loading │ │ └── index.vue │ └── message │ │ ├── MessageComponent.vue │ │ └── index.ts ├── composables │ ├── api.ts │ └── useWebExtensionStorage.ts ├── contentScripts │ ├── index.ts │ └── views │ │ └── App.vue ├── env.ts ├── global.d.ts ├── logic │ ├── common-setup.ts │ ├── index.ts │ └── storage.ts ├── manifest.ts ├── options │ ├── Options.vue │ ├── api │ │ ├── index.ts │ │ └── wbi.ts │ ├── blbl │ │ └── store.ts │ ├── components │ │ ├── Eq │ │ │ ├── Eq.vue │ │ │ └── store.ts │ │ ├── Play │ │ │ ├── LoopSwitch.vue │ │ │ ├── Play.vue │ │ │ ├── keys.ts │ │ │ ├── type.ts │ │ │ └── video.vue │ │ ├── Sider.vue │ │ ├── SingerCard.vue │ │ ├── SingerItem.vue │ │ ├── SongItem.vue │ │ ├── TabItem.vue │ │ ├── sharecard │ │ │ └── index.vue │ │ └── wallpaper-gen │ │ │ └── index.vue │ ├── index.html │ ├── main.ts │ ├── pages │ │ ├── About.vue │ │ ├── Home │ │ │ ├── home-singer.vue │ │ │ ├── index.vue │ │ │ ├── rankOverview.vue │ │ │ ├── scroll-button.vue │ │ │ └── singer-preview.vue │ │ ├── ListenLater.vue │ │ ├── Search.vue │ │ ├── Setting.vue │ │ └── Singer │ │ │ ├── SingerDetail.vue │ │ │ └── SingerList.vue │ ├── playlist │ │ ├── AddCollection.vue │ │ ├── AddSong.vue │ │ ├── BL-Fav.vue │ │ ├── Imp-Fav.vue │ │ ├── index.vue │ │ └── store.ts │ └── utils.ts ├── popup │ ├── Popup.vue │ ├── index.html │ └── main.ts ├── styles │ ├── eno.css │ ├── index.ts │ ├── main.css │ └── variables.css ├── tests │ └── demo.spec.ts └── utils │ └── index.js ├── tsconfig.json ├── unocss.config.ts ├── vite.config.background.mts ├── vite.config.content.mts └── vite.config.mts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | public 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "cache": true, 3 | "cacheLocation": "./node_modules/.cache/eslint" 4 | } 5 | 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: cloudflypeng 2 | -------------------------------------------------------------------------------- /.github/workflows/release-it.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | id-token: write 6 | pull-requests: write 7 | 8 | on: 9 | # push: 10 | # branches: 11 | # - main # 或者你的主分支名称,如 master 12 | workflow_dispatch: 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Configure Git 24 | run: | 25 | git config --global user.name "github-actions[bot]" 26 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 27 | 28 | - name: Setup pnpm 29 | uses: pnpm/action-setup@v3 30 | with: 31 | version: latest 32 | 33 | - name: Setup Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 'lts/*' 37 | 38 | - name: Install dependencies 39 | run: pnpm install --frozen-lockfile 40 | 41 | - name: Build Extension 42 | run: pnpm build 43 | 44 | - name: Pack Extension 45 | run: pnpm pack 46 | - name: Install release-it 47 | run: npm install -g release-it 48 | 49 | - name: Release 50 | run: npx release-it patch --ci 51 | env: 52 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | .vite-ssg-dist 4 | .vite-ssg-temp 5 | *.crx 6 | *.local 7 | *.log 8 | *.pem 9 | *.xpi 10 | *.zip 11 | dist 12 | dist-ssr 13 | extension/manifest.json 14 | node_modules 15 | src/auto-imports.d.ts 16 | src/components.d.ts 17 | .eslintcache 18 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full-vnc 2 | 3 | USER root 4 | 5 | # Install dependencies 6 | RUN apt-get update \ 7 | && apt-get install -y firefox 8 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | 4 | tasks: 5 | - init: pnpm install && pnpm run build 6 | name: dev 7 | command: | 8 | gp sync-done ready 9 | pnpm run dev 10 | - name: pnpm start:chromium 11 | command: | 12 | gp sync-await ready 13 | gp ports await 6080 14 | gp preview $(gp url 6080) 15 | sleep 5 16 | pnpm start:chromium 17 | openMode: split-right 18 | 19 | ports: 20 | - port: 5900 21 | onOpen: ignore 22 | - port: 6080 23 | onOpen: ignore 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | auto-install-peers=true 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "release-it-pnpm": {} 4 | }, 5 | "git": { 6 | "requireBranch": "main", 7 | "commitMessage": "chore: release v${version}" 8 | }, 9 | "github": { 10 | "release": true, 11 | "assets": [ 12 | "*.zip", 13 | "*.xpi", 14 | "*.crx" 15 | ] 16 | }, 17 | "npm": false 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "vue.volar", 4 | "antfu.iconify", 5 | "antfu.unocss", 6 | "dbaeumer.vscode-eslint", 7 | "csstools.postcss" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Bili", 4 | "bilibili", 5 | "Bvid", 6 | "efetch", 7 | "Sider", 8 | "Vitesse" 9 | ], 10 | "typescript.tsdk": "node_modules/typescript/lib", 11 | "vite.autoStart": false, 12 | "eslint.useFlatConfig": true, 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": "always" 15 | }, 16 | "files.associations": { 17 | "*.css": "postcss" 18 | }, 19 | "eslint.validate": [ 20 | "javascript", 21 | "javascriptreact", 22 | "typescript", 23 | "typescriptreact" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | eno music icon
3 |

4 | 5 |

Eno Music

6 | 7 |

基于 bilibili 的音乐播放器, 提供歌手和歌单管理功能

8 | 9 | ## 介绍 10 | 11 | Eno Music 是一个基于 bilibili 的音乐播放器,完全改变了你在该平台上的体验。它重新构想了 bilibili 作为一个音乐平台的样子,并提供了独特而沉浸式的体验。 12 | 13 | [chrome 下载](https://chromewebstore.google.com/detail/eno-m/hjcdffalgapcchmopkbnkljenlglloln?hl=zh-CN&utm_source=ext_sidebar) 14 | 15 | [爱发电](https://afdian.com/a/meanc) 16 | 爱发电充电用户后续我会发邀请,体验新项目 17 | 18 | [Discord](https://discord.gg/HPv2WDrvhq) 19 | 20 | 应用截图 21 | 22 | 应用截图 23 | 24 | ![应用截图](https://github.com/user-attachments/assets/8e5faae6-4e57-4e0d-b13f-ac6b036a611f) 25 | 26 | ## 贡献者 27 | 28 | 29 | 30 | 31 | 32 | Made with [contrib.rocks](https://contrib.rocks). 33 | 34 | RoadMap 35 | 1. 对于收藏夹和收藏夹内容的增删改查 36 | 2. 合集的支持 37 | 3. 分p高级功能 38 | 4. eno歌单导入b站 39 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/referrer.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": [ 3 | { 4 | "id": "bilibili-referrer", 5 | "condition": { 6 | "urlFilter": "bili" 7 | }, 8 | "actions": [ 9 | { 10 | "type": "modifyHeaders", 11 | "header": "Referer", 12 | "value": "https://www.example.com" 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /e2e/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, isDevArtifact, name, test } from './fixtures' 2 | 3 | test('example test', async ({ page }, testInfo) => { 4 | testInfo.skip(!isDevArtifact(), 'contentScript is in closed ShadowRoot mode') 5 | 6 | await page.goto('https://example.com') 7 | 8 | await page.locator(`#${name} button`).click() 9 | await expect(page.locator(`#${name} h1`)).toHaveText('Vitesse WebExt') 10 | }) 11 | 12 | test('popup page', async ({ page, extensionId }) => { 13 | await page.goto(`chrome-extension://${extensionId}/dist/popup/index.html`) 14 | await expect(page.locator('button')).toHaveText('Open Options') 15 | }) 16 | 17 | test('options page', async ({ page, extensionId }) => { 18 | await page.goto(`chrome-extension://${extensionId}/dist/options/index.html`) 19 | await expect(page.locator('img')).toHaveAttribute('alt', 'extension icon') 20 | }) 21 | -------------------------------------------------------------------------------- /e2e/fixtures.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { setTimeout as sleep } from 'node:timers/promises' 3 | import fs from 'fs-extra' 4 | import { type BrowserContext, test as base, chromium } from '@playwright/test' 5 | import type { Manifest } from 'webextension-polyfill' 6 | 7 | export { name } from '../package.json' 8 | 9 | export const extensionPath = path.join(__dirname, '../extension') 10 | 11 | export const test = base.extend<{ 12 | context: BrowserContext 13 | extensionId: string 14 | }>({ 15 | context: async ({ headless }, use) => { 16 | // workaround for the Vite server has started but contentScript is not yet. 17 | await sleep(1000) 18 | const context = await chromium.launchPersistentContext('', { 19 | headless, 20 | args: [ 21 | ...(headless ? ['--headless=new'] : []), 22 | `--disable-extensions-except=${extensionPath}`, 23 | `--load-extension=${extensionPath}`, 24 | ], 25 | }) 26 | await use(context) 27 | await context.close() 28 | }, 29 | extensionId: async ({ context }, use) => { 30 | // for manifest v3: 31 | let [background] = context.serviceWorkers() 32 | if (!background) 33 | background = await context.waitForEvent('serviceworker') 34 | 35 | const extensionId = background.url().split('/')[2] 36 | await use(extensionId) 37 | }, 38 | }) 39 | 40 | export const expect = test.expect 41 | 42 | export function isDevArtifact() { 43 | const manifest: Manifest.WebExtensionManifest = fs.readJsonSync(path.resolve(extensionPath, 'manifest.json')) 44 | return Boolean( 45 | typeof manifest.content_security_policy === 'object' 46 | && manifest.content_security_policy.extension_pages?.includes('localhost'), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@antfu/eslint-config').default() 2 | -------------------------------------------------------------------------------- /extension/_metadata/generated_indexed_rulesets/_ruleset1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/_metadata/generated_indexed_rulesets/_ruleset1 -------------------------------------------------------------------------------- /extension/assets/128px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/assets/128px.png -------------------------------------------------------------------------------- /extension/assets/256px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/assets/256px.png -------------------------------------------------------------------------------- /extension/assets/512px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/assets/512px.png -------------------------------------------------------------------------------- /extension/assets/broken-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/assets/broken-image.png -------------------------------------------------------------------------------- /extension/assets/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/assets/icon-128.png -------------------------------------------------------------------------------- /extension/assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/extension/assets/icon-512.png -------------------------------------------------------------------------------- /extension/assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /extension/assets/referrer.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "priority": 1, 5 | "action": { 6 | "type": "modifyHeaders", 7 | "requestHeaders": [ 8 | { 9 | "header": "origin", 10 | "operation": "set", 11 | "value": "https://www.bilibili.com" 12 | }, 13 | { 14 | "header": "referer", 15 | "operation": "set", 16 | "value": "https://www.bilibili.com" 17 | } 18 | ] 19 | }, 20 | "condition": { 21 | "domainType": "thirdParty", 22 | "urlFilter": "bilivideo.com", 23 | "resourceTypes": ["xmlhttprequest", "media", "websocket", "script"], 24 | "requestMethods": ["post", "get"] 25 | } 26 | }, 27 | { 28 | "id": 2, 29 | "priority": 1, 30 | "action": { 31 | "type": "modifyHeaders", 32 | "requestHeaders": [ 33 | { 34 | "header": "origin", 35 | "operation": "set", 36 | "value": "https://message.bilibili.com/" 37 | }, 38 | { 39 | "header": "referer", 40 | "operation": "set", 41 | "value": "https://message.bilibili.com/" 42 | } 43 | ] 44 | }, 45 | "condition": { 46 | "domainType": "thirdParty", 47 | "urlFilter": "https://api.bilibili.com/x/space/wbi", 48 | "resourceTypes": ["xmlhttprequest", "media", "websocket", "script"], 49 | "requestMethods": ["post", "get"] 50 | } 51 | } 52 | ] 53 | -------------------------------------------------------------------------------- /modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@vue/runtime-core' { 2 | interface ComponentCustomProperties { 3 | $app: { 4 | context: string 5 | } 6 | } 7 | } 8 | 9 | // https://stackoverflow.com/a/64189046/479957 10 | export {} 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "enu-music", 3 | "displayName": "ENO-M", 4 | "version": "5.0.0", 5 | "private": true, 6 | "packageManager": "pnpm@8.15.4", 7 | "description": "A music player extension for Chrome", 8 | "scripts": { 9 | "dev": "npm run clear && cross-env NODE_ENV=development run-p dev:*", 10 | "dev-firefox": "npm run clear && cross-env NODE_ENV=development EXTENSION=firefox run-p dev:*", 11 | "dev:prepare": "esno scripts/prepare.ts", 12 | "dev:background": "npm run build:background -- --mode development", 13 | "dev:web": "vite", 14 | "dev:js": "npm run build:js -- --mode development", 15 | "build": "cross-env NODE_ENV=production run-s clear build:web build:prepare build:background build:js", 16 | "build:prepare": "esno scripts/prepare.ts", 17 | "build:background": "vite build --config vite.config.background.mts", 18 | "build:web": "vite build", 19 | "build:js": "vite build --config vite.config.content.mts", 20 | "pack": "cross-env NODE_ENV=production run-p pack:*", 21 | "pack:zip": "rimraf extension.zip && jszip-cli add extension/* -o ./extension.zip", 22 | "pack:crx": "crx pack extension -o ./extension.crx", 23 | "pack:xpi": "cross-env WEB_EXT_ARTIFACTS_DIR=./ web-ext build --source-dir ./extension --filename extension.xpi --overwrite-dest", 24 | "start:chromium": "web-ext run --source-dir ./extension --target=chromium", 25 | "start:firefox": "web-ext run --source-dir ./extension --target=firefox-desktop", 26 | "clear": "rimraf --glob extension/dist extension/manifest.json extension.*", 27 | "lint": "eslint --cache .", 28 | "test": "vitest test", 29 | "test:e2e": "playwright test", 30 | "postinstall": "simple-git-hooks", 31 | "typecheck": "tsc --noEmit", 32 | "release:major": "release-it major", 33 | "release:minor": "release-it minor", 34 | "release:patch": "release-it patch" 35 | }, 36 | "dependencies": { 37 | "@meanc/webext-fetch": "^0.0.3", 38 | "@types/chrome": "^0.0.270", 39 | "@types/howler": "^2.2.12", 40 | "@types/lodash": "^4.17.13", 41 | "@vueuse/math": "^10.11.0", 42 | "browser-id3-writer": "^6.1.0", 43 | "classnames": "^2.5.1", 44 | "eslint-plugin-simple-import-sort": "^12.1.1", 45 | "file-saver": "^2.0.5", 46 | "howler": "^2.2.4", 47 | "html-to-image": "^1.11.11", 48 | "less": "^4.2.0", 49 | "md5": "^2.3.0", 50 | "nanoid": "^5.0.7", 51 | "pinia": "^2.1.7", 52 | "prettier": "^3.3.2", 53 | "prettier-plugin-tailwindcss": "^0.6.5", 54 | "qrcode": "^1.5.4", 55 | "uuid": "^10.0.0" 56 | }, 57 | "devDependencies": { 58 | "@antfu/eslint-config": "^2.23.2", 59 | "@ffflorian/jszip-cli": "^3.6.2", 60 | "@iconify/json": "^2.2.191", 61 | "@playwright/test": "^1.42.1", 62 | "@types/fs-extra": "^11.0.4", 63 | "@types/md5": "^2.3.5", 64 | "@types/node": "^20.11.26", 65 | "@types/webextension-polyfill": "^0.10.7", 66 | "@typescript-eslint/eslint-plugin": "^7.2.0", 67 | "@unocss/reset": "^0.58.5", 68 | "@vitejs/plugin-vue": "^5.0.4", 69 | "@vue/compiler-sfc": "^3.4.21", 70 | "@vue/test-utils": "^2.4.4", 71 | "@vueuse/core": "^10.9.0", 72 | "chokidar": "^3.6.0", 73 | "cross-env": "^7.0.3", 74 | "crx": "^5.0.1", 75 | "eslint": "^8.57.0", 76 | "eslint-plugin-format": "^0.1.2", 77 | "esno": "^4.7.0", 78 | "fs-extra": "^11.2.0", 79 | "jsdom": "^24.0.0", 80 | "kolorist": "^1.8.0", 81 | "lint-staged": "^15.2.2", 82 | "npm-run-all": "^4.1.5", 83 | "release-it": "^17.6.0", 84 | "release-it-pnpm": "^4.6.3", 85 | "rimraf": "^5.0.5", 86 | "simple-git-hooks": "^2.10.0", 87 | "typescript": "^5.4.2", 88 | "unocss": "^0.58.5", 89 | "unplugin-auto-import": "^0.17.5", 90 | "unplugin-icons": "^0.18.5", 91 | "unplugin-vue-components": "^0.26.0", 92 | "vite": "^5.1.6", 93 | "vitest": "^1.3.1", 94 | "vue": "^3.4.21", 95 | "vue-demi": "^0.14.7", 96 | "web-ext": "^7.11.0", 97 | "webext-bridge": "^6.0.1", 98 | "webextension-polyfill": "^0.10.0" 99 | }, 100 | "simple-git-hooks": { 101 | "pre-commit": "pnpm lint-staged" 102 | }, 103 | "lint-staged": { 104 | "*": "eslint --fix" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @see {@link https://playwright.dev/docs/chrome-extensions Chrome extensions | Playwright} 3 | */ 4 | import { defineConfig } from '@playwright/test' 5 | 6 | export default defineConfig({ 7 | testDir: './e2e', 8 | retries: 2, 9 | webServer: { 10 | command: 'npm run dev', 11 | // start e2e test after the Vite server is fully prepared 12 | url: 'http://localhost:3303/popup/main.ts', 13 | reuseExistingServer: true, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /scripts/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { getManifest } from '../src/manifest' 3 | import { log, r } from './utils' 4 | 5 | export async function writeManifest() { 6 | await fs.writeJSON(r('extension/manifest.json'), await getManifest(), { spaces: 2 }) 7 | log('PRE', 'write manifest.json') 8 | } 9 | 10 | writeManifest() 11 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | // generate stub index.html files for dev entry 2 | import { execSync } from 'node:child_process' 3 | import fs from 'fs-extra' 4 | import chokidar from 'chokidar' 5 | import { isDev, log, port, r } from './utils' 6 | 7 | /** 8 | * Stub index.html to use Vite in development 9 | */ 10 | async function stubIndexHtml() { 11 | const views = [ 12 | 'options', 13 | 'popup', 14 | ] 15 | 16 | for (const view of views) { 17 | await fs.ensureDir(r(`extension/dist/${view}`)) 18 | let data = await fs.readFile(r(`src/${view}/index.html`), 'utf-8') 19 | data = data 20 | .replace('"./main.ts"', `"http://localhost:${port}/${view}/main.ts"`) 21 | .replace('
', '
Vite server did not start
') 22 | await fs.writeFile(r(`extension/dist/${view}/index.html`), data, 'utf-8') 23 | log('PRE', `stub ${view}`) 24 | } 25 | } 26 | 27 | function writeManifest() { 28 | execSync('npx esno ./scripts/manifest.ts', { stdio: 'inherit' }) 29 | } 30 | 31 | writeManifest() 32 | 33 | if (isDev) { 34 | stubIndexHtml() 35 | chokidar.watch(r('src/**/*.html')) 36 | .on('change', () => { 37 | stubIndexHtml() 38 | }) 39 | chokidar.watch([r('src/manifest.ts'), r('package.json')]) 40 | .on('change', () => { 41 | writeManifest() 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import process from 'node:process' 3 | import { bgCyan, black } from 'kolorist' 4 | 5 | export const port = Number(process.env.PORT || '') || 3303 6 | export const r = (...args: string[]) => resolve(__dirname, '..', ...args) 7 | export const isDev = process.env.NODE_ENV !== 'production' 8 | export const isFirefox = process.env.EXTENSION === 'firefox' 9 | 10 | export function log(name: string, message: string) { 11 | console.log(black(bgCyan(` ${name} `)), message) 12 | } 13 | -------------------------------------------------------------------------------- /shim.d.ts: -------------------------------------------------------------------------------- 1 | import type { ProtocolWithReturn } from 'webext-bridge' 2 | 3 | declare module 'webext-bridge' { 4 | export interface ProtocolMap { 5 | // define message protocol types 6 | // see https://github.com/antfu/webext-bridge#type-safe-protocols 7 | 'tab-prev': { title: string | undefined } 8 | 'get-current-tab': ProtocolWithReturn<{ tabId: number }, { title?: string }> 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/512px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/src/assets/512px.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/background/api/bilibili.ts: -------------------------------------------------------------------------------- 1 | import { AHS } from '../utils' 2 | import { BLBL } from '../msg.define' 3 | 4 | const baseUrl = 'https://api.bilibili.com' 5 | 6 | const api = { 7 | [BLBL.GET_COOKIE]: { 8 | url: 'https://bilibili.com', 9 | _fetch: { 10 | method: 'get', 11 | }, 12 | afterHandle: [], 13 | }, 14 | [BLBL.GET_RANK]: { 15 | url: `${baseUrl}/x/copyright-music-publicity/toplist/all_period`, 16 | _fetch: { 17 | method: 'get', 18 | }, 19 | params: { 20 | list_type: 1, // 1: 热榜, 2: 原创榜 21 | }, 22 | afterHandle: AHS.J, 23 | }, 24 | [BLBL.GET_RANK_DETAIL]: { 25 | url: `${baseUrl}/x/copyright-music-publicity/toplist/detail`, 26 | _fetch: { 27 | method: 'get', 28 | }, 29 | params: { 30 | list_id: 0, // 榜单id 31 | }, 32 | afterHandle: AHS.J, 33 | }, 34 | [BLBL.GET_RANK_DETAIL_LIST]: { 35 | url: `${baseUrl}/x/copyright-music-publicity/toplist/music_list`, 36 | _fetch: { 37 | method: 'get', 38 | }, 39 | params: { 40 | list_id: 0, // 榜单id 41 | }, 42 | afterHandle: AHS.J, 43 | }, 44 | [BLBL.GET_SONG_DETAIL]: { 45 | url: `${baseUrl}/audio/music-service-c/web/song/info`, 46 | _fetch: { 47 | method: 'get', 48 | }, 49 | params: { 50 | sid: 0, // 歌曲id 51 | }, 52 | afterHandle: AHS.J, 53 | }, 54 | [BLBL.GET_HIT_SONG]: { 55 | // https://www.bilibili.com/audio/music-service-c/web/menu/hit?ps=20&pn=1 56 | url: `${baseUrl}/audio/music-service-c/web/menu/hit`, 57 | _fetch: { 58 | method: 'get', 59 | }, 60 | params: { 61 | ps: 20, // 每页数量 62 | pn: 1, // 页数 63 | }, 64 | afterHandle: AHS.J, 65 | }, 66 | /// audio/music-service-c/web/song/of-menu 67 | [BLBL.GET_HIT_SONG_LIST]: { 68 | url: `${baseUrl}/audio/music-service-c/web/song/of-menu`, 69 | _fetch: { 70 | method: 'get', 71 | }, 72 | params: { 73 | sid: 0, // 歌单id 74 | ps: 100, // 每页数量 75 | pn: 1, // 页数 76 | }, 77 | afterHandle: AHS.J, 78 | }, 79 | // https://www.bilibili.com/audio/music-service-c/web/url?sid=276736 80 | [BLBL.GET_SONG]: { 81 | url: `${baseUrl}/audio/music-service-c/web/url`, 82 | _fetch: { 83 | method: 'get', 84 | }, 85 | params: { 86 | sid: 0, // 歌曲id 87 | }, 88 | afterHandle: AHS.J, 89 | }, 90 | // https://www.bilibili.com/audio/music-service-c/web/menu/rank 91 | [BLBL.GET_MENU_RANK]: { 92 | url: `${baseUrl}/audio/music-service-c/web/menu/rank`, 93 | _fetch: { 94 | method: 'get', 95 | }, 96 | params: { 97 | ps: 3, // 每页数量 98 | pn: 1, // 页数 99 | }, 100 | afterHandle: AHS.J, 101 | }, 102 | // https://www.bilibili.com/audio/music-service-c/web/song/info 103 | [BLBL.GET_SONG_INFO]: { 104 | url: `${baseUrl}/audio/music-service-c/web/song/info`, 105 | _fetch: { 106 | method: 'get', 107 | }, 108 | params: { 109 | sid: 0, 110 | }, 111 | afterHandle: AHS.J, 112 | }, 113 | // https://api.bilibili.com/x/web-interface/search/type?__refresh__=true&_extra=&context=&page=1&page_size=42&platform=pc&highlight=1&single_column=0&keyword=%E9%82%93%E7%B4%AB%E6%A3%8B&category_id=&search_type=video&dynamic_offset=0&preload=true&com2co=true 114 | [BLBL.SEARCH]: { 115 | url: `${baseUrl}/x/web-interface/search/type`, 116 | _fetch: { 117 | method: 'get', 118 | }, 119 | params: { 120 | page: 1, 121 | page_size: 42, 122 | platform: 'pc', 123 | highlight: 1, 124 | single_column: 0, 125 | keyword: '', 126 | category_id: '', 127 | search_type: 'video', 128 | dynamic_offset: 0, 129 | preload: true, 130 | com2co: true, 131 | }, 132 | afterHandle: AHS.J, 133 | }, 134 | // https://api.bilibili.com/x/player/playurl?fnval=16&bvid=BV1jh4y1G7oT&cid=1157282735 135 | [BLBL.GET_AUDIO_OF_VIDEO]: { 136 | url: `${baseUrl}/x/player/playurl`, 137 | _fetch: { 138 | method: 'get', 139 | }, 140 | params: { 141 | fnval: 16, 142 | bvid: '', 143 | cid: 0, 144 | }, 145 | afterHandle: AHS.J, 146 | }, 147 | // https://api.bilibili.com/x/web-interface/view?bvid=BV1BL411Y7kc 148 | // 需要这个获取cid 149 | [BLBL.GET_VIDEO_INFO]: { 150 | url: `${baseUrl}/x/web-interface/view`, 151 | _fetch: { 152 | method: 'get', 153 | }, 154 | params: { 155 | bvid: '', 156 | }, 157 | afterHandle: AHS.J, 158 | }, 159 | // 获取用户信息 https://api.bilibili.com/x/web-interface/card 160 | [BLBL.GET_USER_INFO]: { 161 | url: `${baseUrl}/x/web-interface/card`, 162 | _fetch: { 163 | method: 'get', 164 | }, 165 | params: { 166 | mid: 0, 167 | }, 168 | afterHandle: AHS.J, 169 | }, 170 | // https://api.bilibili.com/x/web-interface/ranking/v2 171 | [BLBL.GET_RANKING]: { 172 | url: `${baseUrl}/x/web-interface/ranking/v2`, 173 | _fetch: { 174 | method: 'get', 175 | }, 176 | params: { 177 | tid: 3, 178 | }, 179 | afterHandle: AHS.J, 180 | }, 181 | // 音乐榜单的列表https://api.bilibili.com/x/copyright-music-publicity/toplist/all_period 182 | [BLBL.GET_MUSIC_RANK_LIST]: { 183 | url: `${baseUrl}/x/copyright-music-publicity/toplist/all_period`, 184 | _fetch: { 185 | method: 'get', 186 | }, 187 | params: { 188 | list_type: 1, // 变化的 189 | }, 190 | afterHandle: AHS.J, 191 | }, 192 | // 全站音乐榜单 193 | [BLBL.GET_MUSIC_RANK]: { 194 | url: `${baseUrl}/x/copyright-music-publicity/toplist/music_list`, 195 | _fetch: { 196 | method: 'get', 197 | }, 198 | params: { 199 | list_id: 207, // 变化的 200 | }, 201 | afterHandle: AHS.J, 202 | }, 203 | // https://api.bilibili.com/x/v3/fav/resource/list 204 | // 收藏夹信息 205 | [BLBL.GET_FAV_INFO]: { 206 | url: `${baseUrl}/x/v3/fav/resource/list`, 207 | _fetch: { 208 | method: 'get', 209 | }, 210 | params: { 211 | media_id: 0, 212 | ps: 20, 213 | pn: 1, 214 | }, 215 | afterHandle: AHS.J, 216 | }, 217 | } 218 | 219 | export default api 220 | -------------------------------------------------------------------------------- /src/background/api/index.ts: -------------------------------------------------------------------------------- 1 | // import browser from 'webextension-polyfill' 2 | 3 | import { apiListenerFactory } from '../utils' 4 | 5 | import API_BILIBILI from './bilibili' 6 | 7 | // Merge all API objects into one 8 | const FullAPI = Object.assign({}, API_BILIBILI) 9 | // Create a message listener for each API 10 | // const handleMessage = apiListenerFactory(FullAPI) 11 | const apiProxy = apiListenerFactory(FullAPI) 12 | export { 13 | apiProxy, 14 | } 15 | -------------------------------------------------------------------------------- /src/background/contentScriptHMR.ts: -------------------------------------------------------------------------------- 1 | import { isFirefox, isForbiddenUrl } from '~/env' 2 | 3 | // Firefox fetch files from cache instead of reloading changes from disk, 4 | // hmr will not work as Chromium based browser 5 | browser.webNavigation.onCommitted.addListener(({ tabId, frameId, url }) => { 6 | // Filter out non main window events. 7 | if (frameId !== 0) 8 | return 9 | 10 | if (isForbiddenUrl(url)) 11 | return 12 | 13 | // inject the latest scripts 14 | browser.tabs.executeScript(tabId, { 15 | file: `${isFirefox ? '' : '.'}/dist/contentScripts/index.global.js`, 16 | runAt: 'document_end', 17 | }).catch(error => console.error(error)) 18 | }) 19 | -------------------------------------------------------------------------------- /src/background/font-api/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cteros/eno-music/52b324ffd3f426555e82dfe0ad837588d6cf0abf/src/background/font-api/index.ts -------------------------------------------------------------------------------- /src/background/main.ts: -------------------------------------------------------------------------------- 1 | import { backgroundListener } from '@meanc/webext-fetch' 2 | 3 | backgroundListener() 4 | // 点击浏览器图标, 打开选项页 5 | browser.action?.onClicked.addListener(() => { 6 | browser.runtime.openOptionsPage() 7 | }) 8 | -------------------------------------------------------------------------------- /src/background/msg.define.ts: -------------------------------------------------------------------------------- 1 | // 这个文件不要引用其他文件,我怕打包时候会把引用的部分也打包成两分 2 | // en: This file should not reference other files, I am afraid that the referenced part will also be packaged into two parts when packaging 3 | 4 | enum BLBL { 5 | GET_COOKIE = 'getCookie', 6 | GET_RANK = 'getRank', 7 | GET_RANK_DETAIL = 'getRankDetail', 8 | GET_RANK_DETAIL_LIST = 'getRankDetailList', 9 | GET_SONG_DETAIL = 'getSongDetail', 10 | GET_HIT_SONG = 'getHitSong', 11 | GET_HIT_SONG_LIST = 'getHitSongList', 12 | GET_SONG = 'getSong', 13 | GET_MENU_RANK = 'getMenuRank', 14 | GET_SONG_INFO = 'getSongInfo', 15 | SEARCH = 'search', 16 | GET_AUDIO_OF_VIDEO = 'getAudioOfVideo', 17 | GET_VIDEO_INFO = 'getVideoInfo', 18 | GET_USER_INFO = 'getUserInfo', 19 | GET_RANKING = 'getRanking', 20 | GET_MUSIC_RANK_LIST = 'getMusicRankList', 21 | GET_MUSIC_RANK = 'getMusicRank', 22 | GET_FAV_INFO = 'getFavInfo', 23 | } 24 | 25 | const API = { 26 | BLBL, 27 | } 28 | 29 | export { 30 | API, 31 | BLBL, 32 | } 33 | 34 | export default API 35 | -------------------------------------------------------------------------------- /src/background/utils.ts: -------------------------------------------------------------------------------- 1 | // 对于fetch的常见后处理 2 | // 1. 直接返回data 3 | // 2. json化后返回data 4 | 5 | type FetchAfterHandler = ((data: Response) => Promise) | ((data: any) => any) 6 | 7 | function toJsonHandler(data: Response): Promise { 8 | return data.json() 9 | } 10 | function toData(data: Promise): Promise { 11 | return data 12 | } 13 | 14 | // if need sendResponse, use this 15 | // return a FetchAfterHandler function 16 | function sendResponseHandler(sendResponse: any) { 17 | return (data: any) => sendResponse(data) 18 | } 19 | 20 | // 定义后处理流 21 | const AHS: { 22 | J: FetchAfterHandler[] 23 | J_D: FetchAfterHandler[] 24 | J_S: FetchAfterHandler[] 25 | S: FetchAfterHandler[] 26 | } = { 27 | J: [toJsonHandler], 28 | J_D: [toJsonHandler, toData], 29 | J_S: [toJsonHandler, sendResponseHandler], 30 | S: [sendResponseHandler], 31 | } 32 | 33 | interface Message { 34 | contentScriptQuery: string 35 | [key: string]: any 36 | } 37 | 38 | interface _FETCH { 39 | method: string 40 | headers?: { 41 | [key: string]: any 42 | } 43 | body?: any 44 | } 45 | 46 | interface API { 47 | url: string 48 | _fetch: _FETCH 49 | params?: { 50 | [key: string]: any 51 | } 52 | afterHandle: ((response: Response) => Response | Promise)[] 53 | } 54 | // 重载API 可以为函数 55 | type APIFunction = (message: Message, sender?: any, sendResponse?: any) => any 56 | type APIType = API | APIFunction 57 | interface APIMAP { 58 | [key: string]: APIType 59 | } 60 | // 工厂函数API_LISTENER_FACTORY 61 | function apiListenerFactory(API_MAP: APIMAP) { 62 | return (message: Message, sender?: any, sendResponse?: any) => { 63 | const contentScriptQuery = message.contentScriptQuery 64 | // 检测是否有contentScriptQuery 65 | if (!contentScriptQuery || !API_MAP[contentScriptQuery]) 66 | return console.error(`Cannot find this contentScriptQuery: ${contentScriptQuery}`) 67 | if (API_MAP[contentScriptQuery] instanceof Function) 68 | return (API_MAP[contentScriptQuery] as APIFunction)(message, sender, sendResponse) 69 | 70 | try { 71 | let { contentScriptQuery, ...rest } = message 72 | // rest above two part body or params 73 | rest = rest || {} 74 | 75 | let { _fetch, url, params = {}, afterHandle } = API_MAP[contentScriptQuery] as API 76 | const { method, headers, body } = _fetch as _FETCH 77 | const isGET = method.toLocaleLowerCase() === 'get' 78 | // merge params and body 79 | const targetParams = Object.assign({}, params) 80 | let targetBody = Object.assign({}, body) 81 | Object.keys(rest).forEach((key) => { 82 | if (body && body[key] !== undefined) 83 | targetBody[key] = rest[key] 84 | else 85 | targetParams[key] = rest[key] 86 | }) 87 | 88 | // generate params 89 | if (Object.keys(targetParams).length) { 90 | const urlParams = new URLSearchParams() 91 | for (const key in targetParams) { 92 | if (targetParams[key]) 93 | urlParams.append(key, targetParams[key]) 94 | } 95 | 96 | url += `?${urlParams.toString()}` 97 | } 98 | // generate body 99 | if (!isGET) { 100 | targetBody = (headers && headers['Content-Type'] && headers['Content-Type'].includes('application/x-www-form-urlencoded')) 101 | ? new URLSearchParams(targetBody) 102 | : JSON.stringify(targetBody) 103 | } 104 | // get cant take body 105 | const fetchOpt = { method, headers } 106 | if (!isGET) { 107 | Object.assign(fetchOpt, { body: targetBody }) 108 | } 109 | 110 | // fetch and after handle 111 | let baseFunc = fetch(url, fetchOpt) 112 | afterHandle.forEach((func) => { 113 | if (func.name === sendResponseHandler.name && sendResponse) 114 | // sendResponseHandler 是一个特殊的后处理函数,需要传入sendResponse 115 | baseFunc = baseFunc.then(sendResponseHandler(sendResponse)) 116 | else 117 | baseFunc = baseFunc.then(func) 118 | }) 119 | baseFunc.catch(console.error) 120 | return baseFunc 121 | } 122 | catch (e) { 123 | console.error(e) 124 | } 125 | } 126 | } 127 | 128 | export { 129 | type FetchAfterHandler, 130 | toJsonHandler, 131 | toData, 132 | sendResponseHandler, 133 | AHS, 134 | type Message, 135 | type _FETCH, 136 | type API, 137 | type APIMAP, 138 | apiListenerFactory, 139 | } 140 | -------------------------------------------------------------------------------- /src/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | ## Components 2 | 3 | Components in this dir will be auto-registered and on-demand, powered by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). 4 | 5 | Components can be shared in all views. 6 | 7 | ### Icons 8 | 9 | You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/). 10 | 11 | It will only bundle the icons you use. Check out [unplugin-icons](https://github.com/unplugin/unplugin-icons) for more details. 12 | -------------------------------------------------------------------------------- /src/components/SharedSubtitle.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/components/__tests__/Logo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import Logo from '../Logo.vue' 4 | 5 | describe('logo component', () => { 6 | it('should render', () => { 7 | const wrapper = mount(Logo) 8 | 9 | expect(wrapper.html()).toBeTruthy() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/components/dialog/index.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 59 | -------------------------------------------------------------------------------- /src/components/drawer/drawer.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 71 | 72 | 78 | -------------------------------------------------------------------------------- /src/components/drawer/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 56 | 57 | 82 | -------------------------------------------------------------------------------- /src/components/drawer/style.css: -------------------------------------------------------------------------------- 1 | 2 | /* 定义 slide 动画的 CSS */ 3 | @keyframes slide-in-from-left { 4 | 0% { 5 | transform: translateX(-100%); 6 | opacity: 0; 7 | } 8 | 100% { 9 | transform: translate(0); 10 | opacity: 1; 11 | } 12 | } 13 | 14 | @keyframes slide-out-to-left { 15 | 0% { 16 | transform: translate(0); 17 | opacity: 1; 18 | } 19 | 100% { 20 | transform: translateX(-100%); 21 | opacity: 0; 22 | } 23 | } 24 | 25 | @keyframes slide-in-from-right { 26 | 0% { 27 | transform: translateX(0); 28 | opacity: 0; 29 | } 30 | 100% { 31 | transform: translate(-100%); 32 | opacity: 1; 33 | } 34 | } 35 | 36 | @keyframes slide-out-to-right { 37 | 0% { 38 | transform: translate(-100%); 39 | opacity: 1; 40 | } 41 | 100% { 42 | transform: translateX(0); 43 | opacity: 0; 44 | } 45 | } 46 | 47 | @keyframes slide-in-from-top { 48 | 0% { 49 | transform: translateY(-100%); 50 | opacity: 0; 51 | } 52 | 100% { 53 | transform: translate(0); 54 | opacity: 1; 55 | } 56 | } 57 | 58 | @keyframes slide-out-to-top { 59 | 0% { 60 | transform: translate(0); 61 | opacity: 1; 62 | } 63 | 100% { 64 | transform: translateY(-100%); 65 | opacity: 0; 66 | } 67 | } 68 | 69 | @keyframes slide-in-from-bottom { 70 | 0% { 71 | transform: translateY(100%); 72 | opacity: 0; 73 | } 74 | 100% { 75 | transform: translate(0); 76 | opacity: 1; 77 | } 78 | } 79 | 80 | @keyframes slide-out-to-bottom { 81 | 0% { 82 | transform: translate(0); 83 | opacity: 1; 84 | } 85 | 100% { 86 | transform: translateY(100%); 87 | opacity: 0; 88 | } 89 | } 90 | 91 | .drawer-left-enter-active, .drawer-left-leave-active { 92 | animation-duration: 0.3s; 93 | animation-fill-mode: both; 94 | } 95 | 96 | .drawer-left-enter-active { 97 | animation-name: slide-in-from-left; 98 | } 99 | 100 | .drawer-left-leave-active { 101 | animation-name: slide-out-to-left; 102 | } 103 | 104 | .drawer-right-enter-active, .drawer-right-leave-active { 105 | animation-fill-mode: both; 106 | animation: slide-in-from-right 0.3s forwards; 107 | } 108 | 109 | .drawer-right-enter-active { 110 | animation: slide-in-from-right 0.3s forwards; 111 | } 112 | 113 | .drawer-right-leave-active { 114 | animation: slide-out-to-right 0.3s forwards; 115 | } 116 | 117 | .drawer-top-enter-active, .drawer-top-leave-active { 118 | animation-duration: 0.3s; 119 | animation-fill-mode: both; 120 | } 121 | 122 | .drawer-top-enter-active { 123 | animation-name: slide-in-from-top; 124 | } 125 | 126 | .drawer-top-leave-active { 127 | animation-name: slide-out-to-top; 128 | } 129 | 130 | .drawer-bottom-enter-active, .drawer-bottom-leave-active { 131 | animation-duration: 0.3s; 132 | animation-fill-mode: both; 133 | } 134 | 135 | .drawer-bottom-enter-active { 136 | animation-name: slide-in-from-bottom; 137 | } 138 | 139 | .drawer-bottom-leave-active { 140 | animation-name: slide-out-to-bottom; 141 | } 142 | -------------------------------------------------------------------------------- /src/components/loading/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 103 | -------------------------------------------------------------------------------- /src/components/message/MessageComponent.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 51 | -------------------------------------------------------------------------------- /src/components/message/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp, h } from 'vue' 2 | import MessageComponent from './MessageComponent.vue' 3 | 4 | const Message = { 5 | instances: [] as { app: any, container: HTMLElement, instance: any }[], 6 | 7 | show(options: { message: string, type?: 'success' | 'error' | 'warning' | 'info', duration?: number }) { 8 | const { message, type = 'info', duration = 500 } = options 9 | 10 | const currentLen = this.instances.length 11 | if (currentLen) { 12 | this.instances.forEach((item, index) => { 13 | const translateY = (currentLen - index) * 60 14 | const dom = item.container.childNodes[0] as HTMLElement 15 | dom.style.transform = `translateY(${translateY}px)` 16 | }) 17 | } 18 | 19 | const container = document.createElement('div') 20 | document.body.appendChild(container) 21 | 22 | const app = createApp({ 23 | render() { 24 | return h(MessageComponent, { message, type }) 25 | }, 26 | }) 27 | 28 | const instance = app.mount(container) 29 | this.instances.push({ app, container, instance }) 30 | 31 | setTimeout(() => { 32 | this.close(instance) 33 | }, duration) 34 | 35 | return instance 36 | }, 37 | 38 | close(instance: any) { 39 | const index = this.instances.findIndex(item => item.instance === instance) 40 | if (index > -1) { 41 | const { app, container } = this.instances[index] 42 | app.unmount() 43 | document.body.removeChild(container) 44 | this.instances.splice(index, 1) 45 | } 46 | }, 47 | 48 | closeAll() { 49 | this.instances.forEach(({ app, container }) => { 50 | app.unmount() 51 | document.body.removeChild(container) 52 | }) 53 | this.instances = [] 54 | }, 55 | } 56 | 57 | export default Message 58 | -------------------------------------------------------------------------------- /src/composables/api.ts: -------------------------------------------------------------------------------- 1 | import type API from '~/background/msg.define' 2 | import { apiProxy } from '~/background/api/index' 3 | 4 | type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` 5 | ? `${Lowercase}${Uppercase}${CamelCase}` 6 | : Lowercase 7 | 8 | type APIFunction = { 9 | [K in keyof T as CamelCase]: { 10 | [P in keyof T[K]as CamelCase]: (options?: object) => Promise 11 | } 12 | } 13 | 14 | // eslint-disable-next-line ts/no-unsafe-declaration-merging 15 | export interface APIClient extends APIFunction { 16 | biliMusic: { 17 | getMusicRank: (params: { list_id: number }) => Promise 18 | getMusicRankList: () => Promise 19 | } 20 | 21 | } 22 | 23 | // eslint-disable-next-line ts/no-unsafe-declaration-merging 24 | export class APIClient { 25 | private readonly cache = new Map() 26 | 27 | constructor() { 28 | // @ts-expect-error ignore 29 | return new Proxy({}, { 30 | get: (_, namespace) => { // namespace 31 | if (this.cache.has(namespace)) { 32 | return this.cache.get(namespace) 33 | } 34 | else { 35 | const api = new Proxy({}, { 36 | get(_, p) { 37 | return (options?: object) => { 38 | return apiProxy({ 39 | contentScriptQuery: p.toString(), 40 | ...options, 41 | }) 42 | } 43 | }, 44 | }) 45 | this.cache.set(namespace, api) 46 | return api 47 | } 48 | }, 49 | }) 50 | } 51 | } 52 | 53 | const api = new APIClient() 54 | 55 | export function useApiClient() { 56 | return api 57 | } 58 | -------------------------------------------------------------------------------- /src/composables/useWebExtensionStorage.ts: -------------------------------------------------------------------------------- 1 | import { StorageSerializers } from '@vueuse/core' 2 | import { toValue, tryOnScopeDispose, watchWithFilter } from '@vueuse/shared' 3 | import { ref, shallowRef } from 'vue-demi' 4 | import { storage } from 'webextension-polyfill' 5 | 6 | import type { 7 | StorageLikeAsync, 8 | UseStorageAsyncOptions, 9 | } from '@vueuse/core' 10 | import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/shared' 11 | import type { Ref } from 'vue-demi' 12 | import type { Storage } from 'webextension-polyfill' 13 | 14 | export type WebExtensionStorageOptions = UseStorageAsyncOptions 15 | 16 | // https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorage/guess.ts 17 | export function guessSerializerType(rawInit: unknown) { 18 | return rawInit == null 19 | ? 'any' 20 | : rawInit instanceof Set 21 | ? 'set' 22 | : rawInit instanceof Map 23 | ? 'map' 24 | : rawInit instanceof Date 25 | ? 'date' 26 | : typeof rawInit === 'boolean' 27 | ? 'boolean' 28 | : typeof rawInit === 'string' 29 | ? 'string' 30 | : typeof rawInit === 'object' 31 | ? 'object' 32 | : Number.isNaN(rawInit) 33 | ? 'any' 34 | : 'number' 35 | } 36 | 37 | const storageInterface: StorageLikeAsync = { 38 | removeItem(key: string) { 39 | return storage.local.remove(key) 40 | }, 41 | 42 | setItem(key: string, value: string) { 43 | return storage.local.set({ [key]: value }) 44 | }, 45 | 46 | async getItem(key: string) { 47 | const storedData = await storage.local.get(key) 48 | 49 | return storedData[key] 50 | }, 51 | } 52 | 53 | /** 54 | * https://github.com/vueuse/vueuse/blob/658444bf9f8b96118dbd06eba411bb6639e24e88/packages/core/useStorageAsync/index.ts 55 | * 56 | * @param key 57 | * @param initialValue 58 | * @param options 59 | */ 60 | export function useWebExtensionStorage( 61 | key: string, 62 | initialValue: MaybeRefOrGetter, 63 | options: WebExtensionStorageOptions = {}, 64 | ): RemovableRef { 65 | const { 66 | flush = 'pre', 67 | deep = true, 68 | listenToStorageChanges = true, 69 | writeDefaults = true, 70 | mergeDefaults = false, 71 | shallow, 72 | eventFilter, 73 | onError = (e) => { 74 | console.error(e) 75 | }, 76 | } = options 77 | 78 | const rawInit: T = toValue(initialValue) 79 | const type = guessSerializerType(rawInit) 80 | 81 | const data = (shallow ? shallowRef : ref)(initialValue) as Ref 82 | const serializer = options.serializer ?? StorageSerializers[type] 83 | 84 | async function read(event?: { key: string, newValue: string | null }) { 85 | if (event && event.key !== key) 86 | return 87 | 88 | try { 89 | const rawValue = event ? event.newValue : await storageInterface.getItem(key) 90 | if (rawValue == null) { 91 | data.value = rawInit 92 | if (writeDefaults && rawInit !== null) 93 | await storageInterface.setItem(key, await serializer.write(rawInit)) 94 | } 95 | else if (mergeDefaults) { 96 | const value = await serializer.read(rawValue) as T 97 | if (typeof mergeDefaults === 'function') 98 | data.value = mergeDefaults(value, rawInit) 99 | else if (type === 'object' && !Array.isArray(value)) 100 | data.value = { ...(rawInit as Record), ...(value as Record) } as T 101 | else data.value = value 102 | } 103 | else { 104 | data.value = await serializer.read(rawValue) as T 105 | } 106 | } 107 | catch (error) { 108 | onError(error) 109 | } 110 | } 111 | 112 | void read() 113 | 114 | if (listenToStorageChanges) { 115 | const listener = async (changes: Record) => { 116 | for (const [key, change] of Object.entries(changes)) { 117 | await read({ 118 | key, 119 | newValue: change.newValue as string | null, 120 | }) 121 | } 122 | } 123 | 124 | storage.onChanged.addListener(listener) 125 | 126 | tryOnScopeDispose(() => { 127 | storage.onChanged.removeListener(listener) 128 | }) 129 | } 130 | 131 | watchWithFilter( 132 | data, 133 | async () => { 134 | try { 135 | await (data.value == null ? storageInterface.removeItem(key) : storageInterface.setItem(key, await serializer.write(data.value))) 136 | } 137 | catch (error) { 138 | onError(error) 139 | } 140 | }, 141 | { 142 | flush, 143 | deep, 144 | eventFilter, 145 | }, 146 | ) 147 | 148 | return data as RemovableRef 149 | } 150 | -------------------------------------------------------------------------------- /src/contentScripts/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { onMessage } from 'webext-bridge/content-script' 3 | import { createApp } from 'vue' 4 | import App from './views/App.vue' 5 | import { setupApp } from '~/logic/common-setup' 6 | 7 | // Firefox `browser.tabs.executeScript()` requires scripts return a primitive value 8 | (() => { 9 | console.info('[vitesse-webext] Hello world from content script') 10 | 11 | // communication example: send previous tab title from background page 12 | onMessage('tab-prev', ({ data }) => { 13 | console.log(`[vitesse-webext] Navigate from page "${data.title}"`) 14 | }) 15 | 16 | // mount component to context window 17 | const container = document.createElement('div') 18 | container.id = __NAME__ 19 | const root = document.createElement('div') 20 | const styleEl = document.createElement('link') 21 | const shadowDOM = container.attachShadow?.({ mode: __DEV__ ? 'open' : 'closed' }) || container 22 | styleEl.setAttribute('rel', 'stylesheet') 23 | styleEl.setAttribute('href', browser.runtime.getURL('dist/contentScripts/style.css')) 24 | shadowDOM.appendChild(styleEl) 25 | shadowDOM.appendChild(root) 26 | document.body.appendChild(container) 27 | const app = createApp(App) 28 | setupApp(app) 29 | app.mount(root) 30 | })() 31 | -------------------------------------------------------------------------------- /src/contentScripts/views/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | const forbiddenProtocols = [ 2 | 'chrome-extension://', 3 | 'chrome-search://', 4 | 'chrome://', 5 | 'devtools://', 6 | 'edge://', 7 | 'https://chrome.google.com/webstore', 8 | ] 9 | 10 | export function isForbiddenUrl(url: string): boolean { 11 | return forbiddenProtocols.some(protocol => url.startsWith(protocol)) 12 | } 13 | 14 | export const isFirefox = navigator.userAgent.includes('Firefox') 15 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean 2 | /** Extension name, defined in packageJson.name */ 3 | declare const __NAME__: string 4 | 5 | declare module '*.vue' { 6 | const component: any 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /src/logic/common-setup.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | 3 | export function setupApp(app: App) { 4 | // Inject a globally available `$app` object in template 5 | app.config.globalProperties.$app = { 6 | context: '', 7 | } 8 | 9 | // Provide access to `app` in script setup with `const app = inject('app')` 10 | app.provide('app', app.config.globalProperties.$app) 11 | 12 | // Here you can install additional plugins for all contexts: popup, options page and content-script. 13 | // example: app.use(i18n) 14 | // example excluding content-script context: if (context !== 'content-script') app.use(i18n) 15 | } 16 | -------------------------------------------------------------------------------- /src/logic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage' 2 | -------------------------------------------------------------------------------- /src/logic/storage.ts: -------------------------------------------------------------------------------- 1 | import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage' 2 | 3 | export const storageDemo = useWebExtensionStorage('webext-demo', 'Storage Demo') 4 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import type { Manifest } from 'webextension-polyfill' 3 | import type PkgType from '../package.json' 4 | import { isDev, isFirefox, port, r } from '../scripts/utils' 5 | 6 | export async function getManifest() { 7 | const pkg = await fs.readJSON(r('package.json')) as typeof PkgType 8 | 9 | // update this file to update this manifest.json 10 | // can also be conditional based on your need 11 | const manifest: Manifest.WebExtensionManifest = { 12 | manifest_version: 3, 13 | name: pkg.displayName || pkg.name, 14 | version: pkg.version, 15 | description: pkg.description, 16 | declarative_net_request: { 17 | rule_resources: [ 18 | { 19 | id: 'referrer-blbl', 20 | enabled: true, 21 | path: 'assets/referrer.json', 22 | }, 23 | ], 24 | }, 25 | action: { 26 | default_icon: 'assets/128px.png', 27 | // default_popup: './dist/popup/index.html', 28 | }, 29 | options_ui: { 30 | page: './dist/options/index.html', 31 | open_in_tab: true, 32 | }, 33 | background: isFirefox 34 | ? { 35 | scripts: ['dist/background/index.mjs'], 36 | type: 'module', 37 | } 38 | : { 39 | service_worker: './dist/background/index.mjs', 40 | }, 41 | icons: { 42 | 16: 'assets/128px.png', 43 | 48: 'assets/256px.png', 44 | 128: 'assets/512px.png', 45 | }, 46 | permissions: [ 47 | 'storage', 48 | 'cookies', 49 | 'declarativeNetRequest', 50 | 'declarativeNetRequestFeedback', 51 | ], 52 | host_permissions: [ 53 | 'https://*.bilibili.com/*', 54 | 'https://*.bilivideo.com/*', 55 | 'https://*.bilivideo.cn/*', 56 | ], 57 | content_scripts: [ 58 | ], 59 | web_accessible_resources: [ 60 | { 61 | resources: ['dist/contentScripts/style.css'], 62 | matches: [''], 63 | }, 64 | ], 65 | content_security_policy: { 66 | extension_pages: isDev 67 | // this is required on dev for Vite script to load 68 | ? `script-src \'self\' http://localhost:${port}; object-src \'self\'` 69 | : 'script-src \'self\' ; object-src \'self\'', 70 | }, 71 | } 72 | 73 | // FIXME: not work in MV3 74 | if (isDev && false) { 75 | // for content script, as browsers will cache them for each reload, 76 | // we use a background script to always inject the latest version 77 | // see src/background/contentScriptHMR.ts 78 | delete manifest.content_scripts 79 | manifest.permissions?.push('webNavigation') 80 | } 81 | 82 | return manifest 83 | } 84 | -------------------------------------------------------------------------------- /src/options/Options.vue: -------------------------------------------------------------------------------- 1 | 2 | 93 | 94 | 116 | 117 | 184 | -------------------------------------------------------------------------------- /src/options/api/index.ts: -------------------------------------------------------------------------------- 1 | import { efetch } from '@meanc/webext-fetch' 2 | import { encWbi, getWbiKeys } from './wbi' 3 | 4 | async function getUserArc(params: object) { 5 | const defaultParams = { 6 | mid: 0, 7 | pn: 1, 8 | ps: 25, 9 | tid: 3, 10 | keyword: '', 11 | order: 'pubdate', 12 | } 13 | params = { ...defaultParams, ...params } 14 | const web_keys = await getWbiKeys() 15 | const img_key = web_keys.img_key 16 | const sub_key = web_keys.sub_key 17 | const query = encWbi(params, img_key, sub_key) 18 | const res = await fetch(`https://api.bilibili.com/x/space/wbi/arc/search?${query}`, { 19 | method: 'GET', 20 | headers: { 21 | Referer: 'https://message.bilibili.com/', 22 | }, 23 | }) 24 | 25 | return res.json() 26 | } 27 | 28 | async function getSeasonInfo(params: Record) { 29 | const defaultParams = { 30 | mid: 1, 31 | season_id: 0, 32 | } 33 | params = { ...defaultParams, ...params } 34 | const url = `https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?${new URLSearchParams(params).toString()}` 35 | const res = await efetch(url, { 36 | method: 'GET', 37 | 38 | headers: { 39 | Referer: 'https://www.bilibili.com/', 40 | }, 41 | }) 42 | 43 | return res 44 | } 45 | // https://api.bilibili.com/x/v3/fav/folder/created/list-all 46 | function getFavorites({ mid }: { mid: number }) { 47 | const urlserachparams = new URLSearchParams() 48 | urlserachparams.set('type', '0') 49 | urlserachparams.set('up_mid', mid.toString()) 50 | 51 | return efetch(`https://api.bilibili.com/x/v3/fav/folder/created/list-all?${urlserachparams.toString()}`, { 52 | method: 'GET', 53 | }) 54 | } 55 | // https://api.bilibili.com/x/web-interface/nav 56 | const getUserInfo = () => efetch('https://api.bilibili.com/x/web-interface/nav', {}) 57 | // https://api.bilibili.com/x/v3/fav/folder/collected/list 58 | // with page 59 | function getCollectedFavorites({ mid }: { mid: number }) { 60 | const urlserachparams = new URLSearchParams() 61 | urlserachparams.set('up_mid', mid.toString()) 62 | urlserachparams.set('pn', '1') 63 | urlserachparams.set('ps', '70') 64 | urlserachparams.set('platform', 'web') 65 | return efetch(`https://api.bilibili.com/x/v3/fav/folder/collected/list?${urlserachparams.toString()}`, { 66 | method: 'GET', 67 | }) 68 | } 69 | // https://api.bilibili.com/x/v3/fav/resource/infos 70 | function getFavResourceInfos({ id }: { id: number }) { 71 | return efetch(`https://api.bilibili.com/x/v3/fav/resource/infos?${new URLSearchParams({ resources: `${id.toString()}:2` }).toString()}`, { 72 | method: 'GET', 73 | }) 74 | } 75 | 76 | export { getUserArc, getSeasonInfo, getFavorites, getUserInfo, getCollectedFavorites, getFavResourceInfos } 77 | -------------------------------------------------------------------------------- /src/options/api/wbi.ts: -------------------------------------------------------------------------------- 1 | import md5 from 'md5' 2 | 3 | const mixinKeyEncTab: number[] = [ 4 | 46, 5 | 47, 6 | 18, 7 | 2, 8 | 53, 9 | 8, 10 | 23, 11 | 32, 12 | 15, 13 | 50, 14 | 10, 15 | 31, 16 | 58, 17 | 3, 18 | 45, 19 | 35, 20 | 27, 21 | 43, 22 | 5, 23 | 49, 24 | 33, 25 | 9, 26 | 42, 27 | 19, 28 | 29, 29 | 28, 30 | 14, 31 | 39, 32 | 12, 33 | 38, 34 | 41, 35 | 13, 36 | 37, 37 | 48, 38 | 7, 39 | 16, 40 | 24, 41 | 55, 42 | 40, 43 | 61, 44 | 26, 45 | 17, 46 | 0, 47 | 1, 48 | 60, 49 | 51, 50 | 30, 51 | 4, 52 | 22, 53 | 25, 54 | 54, 55 | 21, 56 | 56, 57 | 59, 58 | 6, 59 | 63, 60 | 57, 61 | 62, 62 | 11, 63 | 36, 64 | 20, 65 | 34, 66 | 44, 67 | 52, 68 | ] 69 | 70 | // 对 imgKey 和 subKey 进行字符顺序打乱编码 71 | const getMixinKey = (orig: string): string => mixinKeyEncTab.map(n => orig[n]).join('').slice(0, 32) 72 | 73 | // 为请求参数进行 wbi 签名 74 | function encWbi(params: Record, img_key: string, sub_key: string): string { 75 | const mixin_key = getMixinKey(img_key + sub_key) 76 | const curr_time = Math.round(Date.now() / 1000) 77 | const chr_filter = /[!'()*]/g 78 | 79 | Object.assign(params, { wts: curr_time }) // 添加 wts 字段 80 | // 按照 key 重排参数 81 | const query = Object 82 | .keys(params) 83 | .sort() 84 | .map((key) => { 85 | // 过滤 value 中的 "!'()*" 字符 86 | const value = params[key].toString().replace(chr_filter, '') 87 | return `${encodeURIComponent(key)}=${encodeURIComponent(value)}` 88 | }) 89 | .join('&') 90 | 91 | const wbi_sign = md5(query + mixin_key) // 计算 w_rid 92 | 93 | return `${query}&w_rid=${wbi_sign}` 94 | } 95 | 96 | // 获取最新的 img_key 和 sub_key 97 | async function getWbiKeys(): Promise<{ img_key: string, sub_key: string }> { 98 | const res = await fetch('https://api.bilibili.com/x/web-interface/nav', { 99 | headers: { 100 | // SESSDATA 字段 101 | 'Cookie': 'SESSDATA=xxxxxx', 102 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3', 103 | 'Referer': 'https://www.bilibili.com/', // 对于直接浏览器调用可能不适用 104 | }, 105 | }) 106 | const { data: { wbi_img: { img_url, sub_url } } } = await res.json() 107 | 108 | return { 109 | img_key: img_url.slice( 110 | img_url.lastIndexOf('/') + 1, 111 | img_url.lastIndexOf('.'), 112 | ), 113 | sub_key: sub_url.slice( 114 | sub_url.lastIndexOf('/') + 1, 115 | sub_url.lastIndexOf('.'), 116 | ), 117 | } 118 | } 119 | 120 | export { 121 | encWbi, 122 | getWbiKeys, 123 | } 124 | -------------------------------------------------------------------------------- /src/options/blbl/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { cloneDeep } from 'lodash' 3 | import { useStorage } from '@vueuse/core' 4 | import type { RemovableRef } from '@vueuse/core' 5 | import { useApiClient } from '~/composables/api' 6 | 7 | const api = useApiClient() 8 | 9 | export const VIDEO_MODE = { 10 | FLOATING: 'floating', 11 | DRAWER: 'drawer', 12 | HIDDEN: 'hidden', 13 | } 14 | type VideoMode = typeof VIDEO_MODE[keyof typeof VIDEO_MODE] 15 | interface MusicRankItem { 16 | creation_bvid: string 17 | mv_cover: string 18 | album: string 19 | description: string 20 | singer: string 21 | duration: number 22 | } 23 | 24 | interface State { 25 | howl: any 26 | eqService: any 27 | play: object 28 | playList: RemovableRef 29 | count: number 30 | loopMode: RemovableRef 31 | videoMode: RemovableRef 32 | mode: string 33 | timestampRefreshMap: Record 34 | ranksId: any[] 35 | rankList: any[] 36 | currentRank: object 37 | rankDetailList: any[] 38 | hitList: RemovableRef 39 | currentHit: { 40 | list: any[] 41 | [key: string]: any 42 | } 43 | hit_ps: number 44 | hit_pn: number 45 | musicRankList: RemovableRef 46 | } 47 | 48 | export const useBlblStore = defineStore({ 49 | id: 'blbl', 50 | state: (): State => ({ 51 | howl: null, 52 | eqService: null, 53 | play: useStorage('playInfo', {}), // 当前播放的歌曲信息 54 | playList: useStorage('playList', []), // 播放列表 55 | count: 0, 56 | loopMode: useStorage('loopMode', 'list'), 57 | videoMode: useStorage('videoMode', VIDEO_MODE.FLOATING), 58 | mode: 'home', 59 | timestampRefreshMap: { 60 | biliMusic: 0, 61 | }, 62 | // 按年份的三个 63 | rankList: [], 64 | currentRank: {}, 65 | rankDetailList: [], 66 | // 热度榜单 67 | hitList: useStorage('hitList', []), 68 | currentHit: {}, 69 | hit_ps: 10, 70 | hit_pn: 1, 71 | // 音乐排行榜 72 | ranksId: [], 73 | musicRankList: useStorage('musicRankList', []), 74 | }), 75 | // 计算属性 76 | getters: { 77 | 78 | }, 79 | actions: { 80 | // 初始化首页 81 | initHomePage() { 82 | this.initBiliMusic() 83 | }, 84 | initBiliMusic() { 85 | // 获取排行榜的列表 86 | api.biliMusic.getMusicRankList().then((res) => { 87 | const rankObj = res.data.list 88 | let flatList: any[] = [] 89 | // 按年份的借口,拍平 90 | Object.values(rankObj).forEach((i) => { 91 | flatList = flatList.concat(i) 92 | }) 93 | // 排序 94 | this.ranksId = flatList.sort((a, b) => b.ID - a.ID) 95 | this.getRankById(this.ranksId[0]?.ID) 96 | }) 97 | }, 98 | // 全站音乐榜 99 | getRankById(id: number) { 100 | if (!id) 101 | return 102 | api.biliMusic.getMusicRank({ 103 | list_id: id, 104 | }).then((res) => { 105 | const { data: { list } } = res as { data: { list: MusicRankItem[] } } 106 | if (Array.isArray(list) && list.length > 0) { 107 | this.musicRankList = res.data.list.map((item: MusicRankItem) => { 108 | return { 109 | id: item.creation_bvid, 110 | eno_song_type: 'bvid', 111 | cover: item.mv_cover, 112 | title: item.album, 113 | description: item.description || '', 114 | author: item.singer, 115 | duration: item.duration || 0, 116 | bvid: item.creation_bvid, 117 | } 118 | }) 119 | } 120 | }) 121 | }, 122 | getrankList() { 123 | api.blbl.getMenuRank({ 124 | ps: 3, 125 | }).then((res) => { 126 | this.rankList = res.data.data || [] 127 | }) 128 | }, 129 | getHitList() { 130 | api.blbl.getHitSong({ 131 | ps: this.hit_ps, 132 | pn: this.hit_pn, 133 | }).then((res) => { 134 | this.hitList = res.data.data 135 | }) 136 | }, 137 | startPlay(item: any) { 138 | const song = cloneDeep(item) 139 | this.play = song 140 | const isInList = this.playList.some(item => item?.id === song.id) 141 | if (!isInList) 142 | this.playList.push(song) 143 | }, 144 | getHitDetailList(sid: number) { 145 | api.blbl.getHitSongList({ 146 | sid, 147 | }).then((res) => { 148 | this.currentHit.list = res.data.data 149 | }) 150 | }, 151 | toRankDetail(item: any) { 152 | this.mode = 'rankDetail' 153 | this.currentRank = { ...item } 154 | }, 155 | }, 156 | }) 157 | 158 | export type BlblStore = ReturnType 159 | -------------------------------------------------------------------------------- /src/options/components/Eq/Eq.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 111 | 112 | 251 | -------------------------------------------------------------------------------- /src/options/components/Eq/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { Howler } from 'howler' 3 | 4 | type PresetName = 'flat' | 'pop' | 'rock' | 'jazz' | 'classical' | string 5 | 6 | interface Presets { 7 | flat: number[] 8 | pop: number[] 9 | rock: number[] 10 | jazz: number[] 11 | classical: number[] 12 | } 13 | 14 | interface CustomPresets { 15 | [key: string]: number[] 16 | } 17 | 18 | interface EqState { 19 | presets: Presets 20 | currentPreset: PresetName 21 | values: number[] 22 | customPresets: CustomPresets 23 | } 24 | 25 | export const useEqStore = defineStore('eq', { 26 | state: (): EqState => ({ 27 | // 预设的 EQ 配置 28 | presets: { 29 | flat: [0, 0, 0, 0, 0, 0], 30 | pop: [4, 2, 0, -2, 2, 4], 31 | rock: [4, 2, -2, -2, 2, 4], 32 | jazz: [3, 2, -1, -2, 1, 3], 33 | classical: [3, 2, 0, -1, 2, 4], 34 | }, 35 | // 当前选中的预设 36 | currentPreset: 'flat', 37 | // 当前的 EQ 值 (60Hz, 170Hz, 350Hz, 1kHz, 3.5kHz, 10kHz) 38 | values: [0, 0, 0, 0, 0, 0], 39 | // 自定义预设 40 | customPresets: {}, 41 | }), 42 | 43 | actions: { 44 | setPreset(name: PresetName): void { 45 | this.currentPreset = name 46 | if (this.presets[name as keyof Presets]) { 47 | this.values = [...this.presets[name as keyof Presets]] 48 | } 49 | else if (this.customPresets[name]) { 50 | this.values = [...this.customPresets[name]] 51 | } 52 | }, 53 | 54 | updateValue(index: number, value: number): void { 55 | this.values[index] = value 56 | this.currentPreset = 'custom' 57 | }, 58 | 59 | saveCustomPreset(name: string): void { 60 | this.customPresets[name] = [...this.values] 61 | this.currentPreset = name 62 | }, 63 | 64 | deleteCustomPreset(name: string): void { 65 | delete this.customPresets[name] 66 | if (this.currentPreset === name) { 67 | this.setPreset('flat') 68 | } 69 | }, 70 | 71 | reset(): void { 72 | this.setPreset('flat') 73 | }, 74 | }, 75 | }) 76 | 77 | export class EQService { 78 | private ctx: AudioContext | null = null 79 | private filters: BiquadFilterNode[] = [] 80 | private initialized = false 81 | 82 | constructor() { 83 | this.initializeWhenReady() 84 | } 85 | 86 | private initializeWhenReady(): void { 87 | if (!Howler.ctx) { 88 | setTimeout(() => this.initializeWhenReady(), 100) 89 | return 90 | } 91 | 92 | if (this.initialized) 93 | return 94 | 95 | this.ctx = Howler.ctx 96 | 97 | // 使用与预设值相对应的6个频率 98 | const frequencies = [60, 170, 350, 1000, 3500, 10000] 99 | 100 | frequencies.forEach((freq) => { 101 | const filter = this.ctx.createBiquadFilter() 102 | filter.type = 'peaking' 103 | filter.frequency.value = freq 104 | filter.Q.value = 1 105 | filter.gain.value = 0 106 | this.filters.push(filter) 107 | }) 108 | 109 | // 连接滤波器链 110 | this.filters.reduce((prev, curr) => { 111 | prev.connect(curr) 112 | return curr 113 | }) 114 | 115 | // 连接到 Howler 的主节点 116 | Howler.masterGain.disconnect() 117 | Howler.masterGain.connect(this.filters[0]) 118 | this.filters[this.filters.length - 1].connect(this.ctx.destination) 119 | 120 | this.initialized = true 121 | } 122 | 123 | updateFilters(values: number[]): void { 124 | if (!this.initialized) 125 | return 126 | this.filters.forEach((filter, index) => { 127 | filter.gain.value = values[index] 128 | }) 129 | } 130 | } 131 | 132 | // export const eqService = new EQService() 133 | 134 | // // 创建一个订阅器来监听 store 135 | // export function setupEqStoreWatcher(): void { 136 | // const eqStore = useEqStore() 137 | 138 | // watch( 139 | // () => eqStore.currentPreset, 140 | // (newPreset: string) => { 141 | // const presetValues = eqStore.presets[newPreset as keyof Presets] || eqStore.customPresets[newPreset] 142 | // if (presetValues) { 143 | // eqService.updateFilters(presetValues) 144 | // } 145 | // }, 146 | // { 147 | // immediate: true, 148 | // }, 149 | // ) 150 | // } 151 | -------------------------------------------------------------------------------- /src/options/components/Play/LoopSwitch.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | -------------------------------------------------------------------------------- /src/options/components/Play/Play.vue: -------------------------------------------------------------------------------- 1 | 314 | 315 | 417 | 418 | 482 | -------------------------------------------------------------------------------- /src/options/components/Play/keys.ts: -------------------------------------------------------------------------------- 1 | import { useActiveElement, useMagicKeys, whenever } from '@vueuse/core' 2 | import { logicAnd } from '@vueuse/math' 3 | 4 | interface ControlOptions { 5 | play: () => void 6 | forward?: () => void 7 | back?: () => void 8 | } 9 | 10 | function useControl(callbacks: ControlOptions) { 11 | const activeElement = useActiveElement() 12 | const notUsingInput = computed(() => 13 | activeElement.value?.tagName !== 'INPUT' 14 | && activeElement.value?.tagName !== 'TEXTAREA') 15 | 16 | const keys = useMagicKeys() 17 | 18 | whenever(logicAnd(keys.space, notUsingInput), () => { 19 | callbacks.play() 20 | }) 21 | whenever(logicAnd(keys.arrowRight, notUsingInput), () => { 22 | callbacks.forward?.() 23 | }) 24 | whenever(logicAnd(keys.arrowLeft, notUsingInput), () => { 25 | callbacks.back?.() 26 | }) 27 | } 28 | 29 | export default useControl 30 | -------------------------------------------------------------------------------- /src/options/components/Play/type.ts: -------------------------------------------------------------------------------- 1 | // list, single, random 2 | enum loopModeEnum { 3 | list, 4 | single, 5 | random, 6 | } 7 | 8 | export { 9 | loopModeEnum, 10 | } 11 | -------------------------------------------------------------------------------- /src/options/components/Play/video.vue: -------------------------------------------------------------------------------- 1 | 143 | 144 | 183 | 184 | 190 | -------------------------------------------------------------------------------- /src/options/components/Sider.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 104 | 105 | 124 | -------------------------------------------------------------------------------- /src/options/components/SingerCard.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 69 | 70 | 81 | -------------------------------------------------------------------------------- /src/options/components/SingerItem.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 84 | 85 | 87 | -------------------------------------------------------------------------------- /src/options/components/SongItem.vue: -------------------------------------------------------------------------------- 1 | 126 | 127 | 153 | 154 | 166 | -------------------------------------------------------------------------------- /src/options/components/TabItem.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 37 | -------------------------------------------------------------------------------- /src/options/components/sharecard/index.vue: -------------------------------------------------------------------------------- 1 | 143 | 144 | 236 | 237 | 280 | -------------------------------------------------------------------------------- /src/options/components/wallpaper-gen/index.vue: -------------------------------------------------------------------------------- 1 | 300 | 301 | 365 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ENO-MUSIC 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/options/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createPinia } from 'pinia' 3 | import App from './Options.vue' 4 | // import { setupEqStoreWatcher } from './components/Eq/store' 5 | import { setupApp } from '~/logic/common-setup' 6 | import '../styles' 7 | 8 | const app = createApp(App) 9 | const pinia = createPinia() 10 | 11 | app.use(pinia) 12 | 13 | // setupEqStoreWatcher() 14 | setupApp(app) 15 | app.mount('#app') 16 | -------------------------------------------------------------------------------- /src/options/pages/About.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 68 | -------------------------------------------------------------------------------- /src/options/pages/Home/home-singer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 36 | -------------------------------------------------------------------------------- /src/options/pages/Home/index.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 76 | 77 | 85 | -------------------------------------------------------------------------------- /src/options/pages/Home/rankOverview.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 45 | 46 | 51 | -------------------------------------------------------------------------------- /src/options/pages/Home/scroll-button.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 24 | -------------------------------------------------------------------------------- /src/options/pages/Home/singer-preview.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 76 | 77 | 104 | -------------------------------------------------------------------------------- /src/options/pages/ListenLater.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 65 | 66 | 76 | -------------------------------------------------------------------------------- /src/options/pages/Search.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 129 | -------------------------------------------------------------------------------- /src/options/pages/Setting.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 118 | -------------------------------------------------------------------------------- /src/options/pages/Singer/SingerDetail.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 168 | 169 | 204 | -------------------------------------------------------------------------------- /src/options/pages/Singer/SingerList.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 69 | -------------------------------------------------------------------------------- /src/options/playlist/AddCollection.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 50 | -------------------------------------------------------------------------------- /src/options/playlist/AddSong.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 40 | -------------------------------------------------------------------------------- /src/options/playlist/BL-Fav.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 109 | -------------------------------------------------------------------------------- /src/options/playlist/Imp-Fav.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 95 | -------------------------------------------------------------------------------- /src/options/playlist/index.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 116 | 117 | 126 | -------------------------------------------------------------------------------- /src/options/playlist/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { useLocalStorage } from '@vueuse/core' 3 | import { nanoid } from 'nanoid' 4 | 5 | import { useApiClient } from '~/composables/api' 6 | 7 | const api = useApiClient() 8 | 9 | export interface song { 10 | id: string | number 11 | [key: string]: any 12 | } 13 | 14 | interface playlist { 15 | id: string | number 16 | name: string 17 | songs: song[] 18 | } 19 | 20 | export const defaultSingers = [ 21 | '337312411', // 翠花 22 | '1889545341', // 邓紫棋 23 | '210752', // 真栗 24 | '37754047', // 咻咻满 25 | '20473341', // 一直在吃的周梓琦 26 | '1839002753', // 鹿火 27 | '98573631', // 鹿小草 28 | ] 29 | 30 | export const usePlaylistStore = defineStore({ 31 | id: 'playlist', 32 | state: () => ({ 33 | list: useLocalStorage('playlist', [] as playlist[]), 34 | listenLater: useLocalStorage('listenLater', [] as song[]), 35 | // 待添加的song 36 | songToAdd: null as song | null, 37 | // 添加窗口是否打开 38 | addSongDialog: false, 39 | // 歌手相关 40 | // 用户自定义歌手mid 41 | singers: useLocalStorage('singers', [...defaultSingers] as string[]), 42 | singerCardCache: useLocalStorage('singerCardCache', {} as Record), 43 | // 当前选中的歌手 44 | currentSinger: null as string | null, 45 | // 打开合集 46 | openCollection: false, 47 | collectionInfo: {} as object, 48 | collectionSongs: [] as song[], 49 | // 歌单海报 50 | isShowPoster: false, 51 | posters: [] as string[], 52 | // 用户权限,取决于是否关注了开发者 53 | userPermission: false, 54 | }), 55 | actions: { 56 | startAddSong(song: song) { 57 | this.songToAdd = song 58 | this.addSongDialog = true 59 | }, 60 | // 添加到稍后再听 61 | addToListenLater(song: song) { 62 | this.listenLater.push(song) 63 | }, 64 | addSong(playlistId: string | number) { 65 | const playlist = this.list.find(p => p.id === playlistId) 66 | if (!playlist) 67 | return 68 | playlist.songs.push(this.songToAdd!) 69 | }, 70 | addSongToListenLater() { 71 | this.listenLater.push(this.songToAdd!) 72 | this.addSongDialog = false 73 | }, 74 | removeSong(playlistId: string | number, songId: string | number) { 75 | const playlist = this.list.find(p => p.id === playlistId) 76 | if (!playlist) 77 | return 78 | const index = playlist.songs.findIndex(s => s.id === songId) 79 | if (index === -1) 80 | return 81 | playlist.songs.splice(index, 1) 82 | }, 83 | createPlaylist(name: string, songs: song[] = []) { 84 | const id = nanoid() 85 | this.list.push({ id, name, songs }) 86 | }, 87 | removePlaylist(playlistId: string | number) { 88 | const index = this.list.findIndex(p => p.id === playlistId) 89 | if (index === -1) 90 | return 91 | this.list.splice(index, 1) 92 | }, 93 | // 获取歌手信息 94 | fetchSingerInfoList() { 95 | if (this.singers.length === 0) { 96 | this.singers = [...defaultSingers] 97 | } 98 | // 获取用户添加的歌手信息 99 | this.singers.forEach((mid) => { 100 | this.fetchSingerInfo(mid) 101 | }) 102 | // // 获取推荐歌手信息 103 | // defaultSingers.forEach((mid) => { 104 | // this.fetchSingerInfo(mid) 105 | // }) 106 | }, 107 | // 获取单个歌手信息 108 | fetchSingerInfo(mid: string, withCache = true) { 109 | if (this.singerCardCache[mid] && withCache) 110 | return 111 | this.singerCardCache[mid] = null 112 | api.blbl.getUserInfo({ mid }).then((res) => { 113 | this.singerCardCache[mid] = res.data.card 114 | }) 115 | }, 116 | addSinger(mid: string) { 117 | this.singers.push(mid) 118 | this.fetchSingerInfo(mid, false) 119 | }, 120 | initUserPermission() { 121 | api.blbl.getUserInfo({ mid: '184327681' }).then((res) => { 122 | this.userPermission = res.data.mid === '184327681' || res.data.following 123 | }) 124 | }, 125 | removeSinger(mid: string) { 126 | const index = this.singers.findIndex(s => s === mid) 127 | if (index === -1) 128 | return 129 | this.singers.splice(index, 1) 130 | }, 131 | }, 132 | 133 | }) 134 | -------------------------------------------------------------------------------- /src/options/utils.ts: -------------------------------------------------------------------------------- 1 | export function download() { } 2 | -------------------------------------------------------------------------------- /src/popup/Popup.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Popup 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/popup/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './Popup.vue' 3 | import { setupApp } from '~/logic/common-setup' 4 | import '../styles' 5 | 6 | const app = createApp(App) 7 | setupApp(app) 8 | app.mount('#app') 9 | -------------------------------------------------------------------------------- /src/styles/eno.css: -------------------------------------------------------------------------------- 1 | .eno-input{ 2 | border-radius: 10cqh; 3 | box-sizing: border-box; 4 | 5 | border-color: none; 6 | outline: none; 7 | outline-width: 2px; 8 | transition: all 0.3s; 9 | transition-timing-function: ease-in-out; 10 | border: 1px solid rgba(0, 0, 0, 0.2); 11 | } 12 | .eno-input:focus{ 13 | border-color: yellow; 14 | box-shadow: 0 0 3px yellow; 15 | outline: none; 16 | outline-width: 2px; 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/index.ts: -------------------------------------------------------------------------------- 1 | import '@unocss/reset/tailwind.css' 2 | import './main.css' 3 | import './variables.css' 4 | import 'uno.css' 5 | 6 | import './eno.css' 7 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #app { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | .btn { 9 | @apply px-4 py-1 rounded inline-block 10 | text-white cursor-pointer 11 | disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50; 12 | } 13 | 14 | .icon-btn { 15 | @apply inline-block cursor-pointer select-none 16 | opacity-75 transition duration-200 ease-in-out 17 | hover:opacity-100 hover:text-teal-600; 18 | font-size: 0.9em; 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --eno-bg: hsl(234, 24%, 8%); 3 | 4 | --eno-text-1: hsla(234, 16%, 98%, 1); 5 | --eno-text-2: hsla(234, 16%, 90%, 0.9); 6 | --eno-text-3: hsla(234, 16%, 80%, 0.76); 7 | --eno-text-4: hsla(234, 16%, 70%, 0.64); 8 | 9 | --eno-content: hsla(234, 16%, 16%, 0.6); 10 | --eno-content-hover: hsla(234, 16%, 26%, 0.6); 11 | 12 | --eno-elevated: hsla(234, 16%, 22%, 0.6); 13 | 14 | --eno-fill-dark-1: hsla(246, 4%, 2%, 0.14); 15 | --eno-fill-dark-2: hsla(246, 4%, 2%, 0.24); 16 | --eno-fill-dark-3: hsla(246, 4%, 2%, 0.34); 17 | --eno-fill-dark-4: hsla(246, 4%, 2%, 0.44); 18 | 19 | --eno-fill-1: hsla(246, 4%, 42%, 0.14); 20 | --eno-fill-2: hsla(246, 4%, 42%, 0.24); 21 | --eno-fill-3: hsla(246, 4%, 42%, 0.34); 22 | --eno-fill-4: hsla(246, 4%, 42%, 0.44); 23 | 24 | --eno-border: var(--eno-fill-2); 25 | 26 | --eno-filter-glass-1: blur(20px) saturate(180%); 27 | --eno-filter-glass-2: blur(40px) saturate(180%); 28 | 29 | --eno-filter-glass-light-1: blur(20px) saturate(180%) brightness(1.2); 30 | --eno-filter-glass-light-2: blur(40px) saturate(180%) brightness(1.2); 31 | } -------------------------------------------------------------------------------- /src/tests/demo.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | describe('demo', () => { 4 | it('should work', () => { 5 | expect(1 + 1).toBe(2) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | // 将秒数转换成时间显示 00:00:00 2 | export function formatTime(time) { 3 | const minute = Math.floor(time / 60) 4 | const second = Math.floor(time % 60) 5 | return `${minute < 10 ? `0${minute}` : minute}:${second < 10 ? `0${second}` : second}` 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": false, 4 | "target": "es2016", 5 | "jsx": "preserve", 6 | "lib": [ 7 | "DOM", 8 | "ESNext" 9 | ], 10 | "baseUrl": ".", 11 | "module": "ESNext", 12 | "moduleResolution": "node", 13 | "paths": { 14 | "~/*": [ 15 | "src/*" 16 | ] 17 | }, 18 | "resolveJsonModule": true, 19 | "types": [ 20 | "vite/client", 21 | "chrome" 22 | ], 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "esModuleInterop": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "skipLibCheck": true 28 | }, 29 | "exclude": [ 30 | "dist", 31 | "node_modules" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'unocss/vite' 2 | import { presetAttributify, presetIcons, presetUno, transformerDirectives } from 'unocss' 3 | 4 | export default defineConfig({ 5 | presets: [ 6 | presetUno(), 7 | presetAttributify(), 8 | presetIcons(), 9 | ], 10 | transformers: [ 11 | transformerDirectives(), 12 | ], 13 | shortcuts: { 14 | 'btn': 'bg-$eno-fill-2 hover:bg-$eno-fill-2 px-6 py-1 h-10 rounded-10 cursor-pointer', 15 | 'btn-primary': 'btn bg-$eno-fill-2 h-10', 16 | 'hov-item': 'hover:bg-$eno-fill-1 cursor-pointer p-2 pr-4 rounded-4 transition-all duration-300', 17 | 'has-border': 'border border-$eno-border', 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /vite.config.background.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { sharedConfig } from './vite.config.mjs' 3 | import { isDev, r } from './scripts/utils' 4 | import packageJson from './package.json' 5 | 6 | // bundling the content script using Vite 7 | export default defineConfig({ 8 | ...sharedConfig, 9 | define: { 10 | '__DEV__': isDev, 11 | '__NAME__': JSON.stringify(packageJson.name), 12 | // https://github.com/vitejs/vite/issues/9320 13 | // https://github.com/vitejs/vite/issues/9186 14 | 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 15 | }, 16 | build: { 17 | watch: isDev 18 | ? {} 19 | : undefined, 20 | outDir: r('extension/dist/background'), 21 | cssCodeSplit: false, 22 | emptyOutDir: false, 23 | sourcemap: isDev ? 'inline' : false, 24 | lib: { 25 | entry: r('src/background/main.ts'), 26 | name: packageJson.name, 27 | formats: ['iife'], 28 | }, 29 | rollupOptions: { 30 | output: { 31 | entryFileNames: 'index.mjs', 32 | extend: true, 33 | }, 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /vite.config.content.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { sharedConfig } from './vite.config.mjs' 3 | import { isDev, r } from './scripts/utils' 4 | import packageJson from './package.json' 5 | 6 | // bundling the content script using Vite 7 | export default defineConfig({ 8 | ...sharedConfig, 9 | define: { 10 | '__DEV__': isDev, 11 | '__NAME__': JSON.stringify(packageJson.name), 12 | // https://github.com/vitejs/vite/issues/9320 13 | // https://github.com/vitejs/vite/issues/9186 14 | 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'), 15 | }, 16 | build: { 17 | watch: isDev 18 | ? {} 19 | : undefined, 20 | outDir: r('extension/dist/contentScripts'), 21 | cssCodeSplit: false, 22 | emptyOutDir: false, 23 | sourcemap: isDev ? 'inline' : false, 24 | lib: { 25 | entry: r('src/contentScripts/index.ts'), 26 | name: packageJson.name, 27 | formats: ['iife'], 28 | }, 29 | rollupOptions: { 30 | output: { 31 | entryFileNames: 'index.global.js', 32 | extend: true, 33 | }, 34 | }, 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { dirname, relative } from 'node:path' 4 | import type { UserConfig } from 'vite' 5 | import { defineConfig } from 'vite' 6 | import Vue from '@vitejs/plugin-vue' 7 | import Icons from 'unplugin-icons/vite' 8 | import IconsResolver from 'unplugin-icons/resolver' 9 | import Components from 'unplugin-vue-components/vite' 10 | import AutoImport from 'unplugin-auto-import/vite' 11 | import UnoCSS from 'unocss/vite' 12 | import { isDev, port, r } from './scripts/utils' 13 | import packageJson from './package.json' 14 | 15 | export const sharedConfig: UserConfig = { 16 | root: r('src'), 17 | server: { 18 | headers: { 19 | 'Cross-Origin-Embedder-Policy': 'require-corp', 20 | 'Cross-Origin-Opener-Policy': 'same-origin', 21 | }, 22 | }, 23 | resolve: { 24 | alias: { 25 | '~/': `${r('src')}/`, 26 | 'jsmediatags': 'jsmediatags/dist/jsmediatags.min.js', 27 | }, 28 | }, 29 | define: { 30 | __DEV__: isDev, 31 | __NAME__: JSON.stringify(packageJson.name), 32 | }, 33 | plugins: [ 34 | Vue(), 35 | 36 | AutoImport({ 37 | imports: [ 38 | 'vue', 39 | { 40 | 'webextension-polyfill': [ 41 | ['*', 'browser'], 42 | ], 43 | }, 44 | ], 45 | dts: r('src/auto-imports.d.ts'), 46 | }), 47 | 48 | // https://github.com/antfu/unplugin-vue-components 49 | Components({ 50 | dirs: [r('src/components')], 51 | // generate `components.d.ts` for ts support with Volar 52 | dts: r('src/components.d.ts'), 53 | resolvers: [ 54 | // auto import icons 55 | IconsResolver({ 56 | prefix: '', 57 | }), 58 | ], 59 | }), 60 | 61 | // https://github.com/antfu/unplugin-icons 62 | Icons(), 63 | 64 | // https://github.com/unocss/unocss 65 | UnoCSS(), 66 | 67 | // rewrite assets to use relative path 68 | { 69 | name: 'assets-rewrite', 70 | enforce: 'post', 71 | apply: 'build', 72 | transformIndexHtml(html, { path }) { 73 | return html.replace(/"\/assets\//g, `"${relative(dirname(path), '/assets')}/`) 74 | }, 75 | }, 76 | ], 77 | optimizeDeps: { 78 | include: [ 79 | 'vue', 80 | '@vueuse/core', 81 | 'webextension-polyfill', 82 | ], 83 | exclude: ['vue-demi'], 84 | }, 85 | worker: { 86 | format: 'es', 87 | }, 88 | } 89 | 90 | export default defineConfig(({ command }) => ({ 91 | ...sharedConfig, 92 | base: command === 'serve' ? `http://localhost:${port}/` : '/dist/', 93 | server: { 94 | port, 95 | hmr: { 96 | host: 'localhost', 97 | }, 98 | origin: `http://localhost:${port}`, 99 | }, 100 | build: { 101 | watch: isDev 102 | ? {} 103 | : undefined, 104 | outDir: r('extension/dist'), 105 | emptyOutDir: false, 106 | sourcemap: isDev ? 'inline' : false, 107 | // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements 108 | terserOptions: { 109 | mangle: false, 110 | }, 111 | rollupOptions: { 112 | input: { 113 | options: r('src/options/index.html'), 114 | popup: r('src/popup/index.html'), 115 | }, 116 | }, 117 | }, 118 | test: { 119 | globals: true, 120 | environment: 'jsdom', 121 | }, 122 | })) 123 | --------------------------------------------------------------------------------