├── .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 | 
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 | 
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 |
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 |
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 |
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 |
2 |
3 |
4 |
5 |
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 |
2 |
3 | This is the {{ $app.context }} page
4 |
5 |
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 |
37 |
38 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/components/drawer/drawer.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
45 |
46 |
68 |
69 |
70 |
71 |
72 |
78 |
--------------------------------------------------------------------------------
/src/components/drawer/index.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
34 |
53 |
54 |
55 |
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 |
2 |
3 |
4 | Loading...
5 |
6 |
7 |
8 |
9 |
103 |
--------------------------------------------------------------------------------
/src/components/message/MessageComponent.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | {{ message }}
12 |
13 |
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 |
10 |
11 |
18 |
19 | Vitesse WebExt
20 |
21 |
22 |
23 |
30 |
31 |
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 |
95 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
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