├── .nvmrc ├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── .browserslistrc ├── .stylelintignore ├── src ├── common │ ├── utils │ │ ├── index.ts │ │ ├── fav.ts │ │ ├── json.ts │ │ ├── number.ts │ │ ├── file.ts │ │ ├── str.ts │ │ ├── time.ts │ │ ├── url.ts │ │ └── color.ts │ ├── constants │ │ ├── collection.ts │ │ ├── vip.ts │ │ ├── relation.ts │ │ ├── audio.tsx │ │ ├── menus.tsx │ │ └── video.ts │ └── broadcast │ │ └── mini-player-sync.ts ├── assets │ ├── images │ │ └── fallback.png │ └── icons │ │ ├── audio-download.svg │ │ ├── video-download.svg │ │ ├── audio-animation.svg │ │ └── logo.svg ├── layout │ ├── playbar │ │ ├── constants.tsx │ │ ├── left │ │ │ └── video-page-list │ │ │ │ ├── utils.ts │ │ │ │ └── menu.tsx │ │ ├── index.tsx │ │ ├── right │ │ │ ├── play-mode.tsx │ │ │ ├── mv-fav-folder-select.tsx │ │ │ ├── play-list-drawer │ │ │ │ └── settings.tsx │ │ │ ├── index.tsx │ │ │ └── rate.tsx │ │ └── center │ │ │ ├── progress.tsx │ │ │ └── index.tsx │ ├── side │ │ ├── index.tsx │ │ ├── default-menu │ │ │ └── index.tsx │ │ └── logo │ │ │ └── index.tsx │ ├── navbar │ │ ├── index.tsx │ │ ├── navigation │ │ │ └── index.tsx │ │ └── login │ │ │ └── index.tsx │ └── index.tsx ├── service │ ├── README.md │ ├── web-buvid.ts │ ├── passport-login-web-key.ts │ ├── passport-login-web-qrcode-generate.ts │ ├── generic-country-list.ts │ ├── passport-login-captcha.ts │ ├── history-toview-clear.ts │ ├── passport-login-web-country.ts │ ├── fav-season-fav.ts │ ├── fav-folder-fav.ts │ ├── fav-folder-unfav.ts │ ├── fav-season-unfav.ts │ ├── history-toview-del.ts │ ├── web-interface-archive-desc.ts │ ├── fav-folder-del.ts │ ├── music-hot-rank.ts │ ├── request │ │ ├── axios.d.ts │ │ ├── index.ts │ │ └── request-interceptors.ts │ ├── history-toview-add.ts │ ├── passport-login-web-qrcode-poll.ts │ ├── passport-login-web-sms-send.ts │ ├── fav-resource-clean.ts │ ├── fav-folder-deal.ts │ ├── passport-login-web-cookie-info.ts │ ├── relation-modify.ts │ ├── fav-folder-add.ts │ ├── fav-folder-edit.ts │ ├── passport-login-web-confirm-refresh.ts │ ├── fav-resource-batch-del.ts │ ├── passport-login-web-login-sms.ts │ ├── musician-list.ts │ ├── passport-login-web-login-passport.ts │ ├── relation-stat.ts │ ├── passport-login-exit.ts │ ├── fav-video-favoured.ts │ ├── gaia-vgate-validate.ts │ ├── space-setting.ts │ ├── player-pagelist.ts │ ├── fav-resource-utils.ts │ ├── music-comprehensive-web-rank.ts │ ├── gaia-vgate-register.ts │ ├── fav-resource-copy.ts │ ├── fav-resource-move.ts │ ├── passport-login-web-cookie-refresh.ts │ ├── space-wbi-acc-relation.ts │ ├── user-video-archives-list.ts │ ├── web-bili-ticket.ts │ ├── fav-folder-created-list.ts │ ├── history-toview-list.ts │ ├── space-navnum.ts │ ├── fav-folder-created-list-all.ts │ ├── audio-web-url.ts │ └── space-top-arc.ts ├── pages │ ├── download-list │ │ └── status-desc.ts │ ├── empty │ │ └── index.tsx │ ├── search │ │ ├── search-type.tsx │ │ ├── user-list.tsx │ │ └── video-list.tsx │ ├── mini-player │ │ ├── play-state.ts │ │ └── use-style.ts │ ├── not-found │ │ └── index.tsx │ ├── video-collection │ │ ├── index.tsx │ │ └── utils.ts │ ├── later │ │ └── action.tsx │ ├── music-rank │ │ └── index.tsx │ ├── follow-list │ │ └── user-card.tsx │ └── user-profile │ │ └── favorites.tsx ├── components │ ├── if │ │ └── index.tsx │ ├── empty │ │ └── index.tsx │ ├── image-card │ │ ├── skeleton.tsx │ │ └── index.tsx │ ├── menu │ │ ├── menu-group.tsx │ │ └── menu-item.tsx │ ├── typography │ │ └── index.tsx │ ├── async-button │ │ └── index.tsx │ ├── scroll-container │ │ └── index.tsx │ ├── error-fallback │ │ └── index.tsx │ ├── media-item │ │ └── index.tsx │ ├── update-check-button │ │ └── index.tsx │ ├── font-select │ │ └── index.tsx │ ├── color-picker │ │ └── index.tsx │ └── select-all-checkbox-group │ │ └── index.tsx ├── index.tsx ├── types │ ├── react-env.d.ts │ └── geetest.d.ts ├── index.html ├── store │ ├── app-update.ts │ ├── token.ts │ └── search-history.ts ├── app.css ├── hero.ts └── routes.tsx ├── .npmrc ├── shared ├── types │ ├── reset.d.ts │ ├── app-setting.d.ts │ ├── app-update.d.ts │ ├── user.d.ts │ └── download.d.ts ├── path │ └── index.ts └── settings │ └── app-settings.ts ├── electron ├── updater │ └── dev-app-update.yml ├── icons │ ├── logo.png │ ├── win │ │ ├── logo.ico │ │ ├── next.png │ │ ├── pause.png │ │ ├── play.png │ │ ├── prev.png │ │ └── tray.ico │ └── macos │ │ ├── icon.icns │ │ ├── dark-icon.png │ │ └── light-icon.png ├── ipc │ ├── types.ts │ ├── font.ts │ ├── cookie.ts │ ├── mini-player.ts │ ├── index.ts │ ├── api │ │ ├── dash-url.ts │ │ └── audio-stream-url.ts │ ├── download │ │ ├── types.ts │ │ ├── utils.ts │ │ └── ffmpeg-processor.ts │ ├── window.ts │ ├── download.ts │ ├── app.ts │ ├── dialog.ts │ ├── store.ts │ └── channel.ts ├── network │ ├── user-agent.ts │ ├── web-buvid.ts │ └── interceptor.ts ├── mac │ └── dock.ts ├── store.ts ├── windows │ └── thumbar.ts ├── utils.ts └── mini-player.ts ├── screenshots ├── home.png ├── main.png └── logo.svg ├── commitlint.config.mjs ├── postcss.config.mjs ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.yml │ └── bug-report.yml └── workflows │ ├── knip-report.yml │ ├── eslint-review.yml │ └── release.yml ├── .editorconfig ├── .prettierrc ├── vitest.config.ts ├── knip.json ├── changelog.config.json ├── plugins ├── mac │ └── entitlements.mac.plist └── rsbuild-plugin-electron.ts ├── stylelint.config.mjs ├── tsconfig.json ├── .vscode └── settings.json ├── rsbuild.config.ts ├── tests └── setup.ts └── .gitignore /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.17.1 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | chrome >= 138 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | public/ 3 | .electron/ 4 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./time"; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*@heroui/* 2 | engine-strict=true 3 | -------------------------------------------------------------------------------- /shared/types/reset.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset"; 2 | -------------------------------------------------------------------------------- /electron/updater/dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: github 2 | owner: wood3n 3 | repo: biu 4 | -------------------------------------------------------------------------------- /screenshots/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/screenshots/home.png -------------------------------------------------------------------------------- /screenshots/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/screenshots/main.png -------------------------------------------------------------------------------- /electron/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/logo.png -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /electron/icons/win/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/win/logo.ico -------------------------------------------------------------------------------- /electron/icons/win/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/win/next.png -------------------------------------------------------------------------------- /electron/icons/win/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/win/pause.png -------------------------------------------------------------------------------- /electron/icons/win/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/win/play.png -------------------------------------------------------------------------------- /electron/icons/win/prev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/win/prev.png -------------------------------------------------------------------------------- /electron/icons/win/tray.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/win/tray.ico -------------------------------------------------------------------------------- /electron/icons/macos/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/macos/icon.icns -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/assets/images/fallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/src/assets/images/fallback.png -------------------------------------------------------------------------------- /electron/icons/macos/dark-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/macos/dark-icon.png -------------------------------------------------------------------------------- /electron/icons/macos/light-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wood3n/biu/HEAD/electron/icons/macos/light-icon.png -------------------------------------------------------------------------------- /src/common/utils/fav.ts: -------------------------------------------------------------------------------- 1 | /** 是否个人私密收藏夹 */ 2 | export const isPrivateFav = (attr: number) => { 3 | return (attr & 1) === 1; 4 | }; 5 | -------------------------------------------------------------------------------- /src/common/constants/collection.ts: -------------------------------------------------------------------------------- 1 | export enum CollectionType { 2 | /** 收藏夹 */ 3 | Favorite = 11, 4 | /** 视频合集 */ 5 | VideoSeries = 21, 6 | } 7 | -------------------------------------------------------------------------------- /src/common/broadcast/mini-player-sync.ts: -------------------------------------------------------------------------------- 1 | export function createBroadcastChannel() { 2 | return new BroadcastChannel("play-list-store-sync-channel"); 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wood3n 2 | src/** @wood3n 3 | electron/** @wood3n 4 | shared/** @wood3n 5 | package.json @wood3n 6 | pnpm-lock.yaml @wood3n 7 | .github/** @wood3n 8 | -------------------------------------------------------------------------------- /electron/ipc/types.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from "electron"; 2 | 3 | export interface IpcHandlerProps { 4 | getMainWindow: () => BrowserWindow | null; 5 | } 6 | -------------------------------------------------------------------------------- /electron/network/user-agent.ts: -------------------------------------------------------------------------------- 1 | export const UserAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36`; 2 | -------------------------------------------------------------------------------- /src/common/utils/json.ts: -------------------------------------------------------------------------------- 1 | export function safeJSONParse(jsonString: string): T | null { 2 | try { 3 | return JSON.parse(jsonString) as T; 4 | } catch { 5 | return null; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/common/constants/vip.ts: -------------------------------------------------------------------------------- 1 | export enum VipType { 2 | /** 3 | * 无会员 4 | */ 5 | None = 0, 6 | /** 7 | * 月会员 8 | */ 9 | MonthVip = 1, 10 | /** 11 | * 年会员 12 | */ 13 | YearVip = 2, 14 | } 15 | -------------------------------------------------------------------------------- /src/layout/playbar/constants.tsx: -------------------------------------------------------------------------------- 1 | export const PlayBarIconSize = { 2 | MainControlIconSize: 48, 3 | SecondControlIconSize: 22, 4 | ThirdControlIconSize: 18, 5 | SideIconSize: 18, 6 | }; 7 | 8 | export const PlayRate = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; 9 | -------------------------------------------------------------------------------- /src/service/README.md: -------------------------------------------------------------------------------- 1 | prompt: 2 | 3 | 使用 fetch 获取 https://socialsisteryi.github.io/bilibili-API-collect/#%F0%9F%8D%B4%E7%9B%AE%E5%BD%95 中目录下的所有链接的内容,并根据每个内容中的定义的接口,生成对应的接口方法和请求参数、返回值类型定义,接口方法从 src\service\request\index.ts 导入,文件名以接口 pathname 分段+连字符组成,文件生成到 src\service 目录下。 4 | -------------------------------------------------------------------------------- /src/common/constants/relation.ts: -------------------------------------------------------------------------------- 1 | export enum UserRelation { 2 | /** 未关注 */ 3 | Unfollowed = 0, 4 | /** 悄悄关注(已弃用) */ 5 | QuietFollowed = 1, 6 | /** 已关注 */ 7 | Followed = 2, 8 | /** 已互粉 */ 9 | MutualFollowed = 6, 10 | /** 已拉黑 */ 11 | Blocked = 128, 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/download-list/status-desc.ts: -------------------------------------------------------------------------------- 1 | export const StatusDesc: Record = { 2 | waiting: "等待中", 3 | downloading: "下载中", 4 | paused: "已暂停", 5 | merging: "合并中", 6 | converting: "转换中", 7 | completed: "已完成", 8 | failed: "下载错误", 9 | }; 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # 禁用空白 Issue,强制用户必须选择一个模板 2 | blank_issues_enabled: false 3 | 4 | # (可选) 在 Issue 选择页面显示指向 Discussions 或文档的链接 5 | contact_links: 6 | - name: 🤔 提问 / 讨论 7 | url: https://github.com/YOUR_USER/YOUR_REPO/discussions 8 | about: 如果这不是一个 Bug,请去讨论区提问。 9 | -------------------------------------------------------------------------------- /src/pages/empty/index.tsx: -------------------------------------------------------------------------------- 1 | import Empty from "@/components/empty"; 2 | 3 | const EmptyPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default EmptyPage; 12 | -------------------------------------------------------------------------------- /src/components/if/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | condition: unknown; 5 | children: React.ReactNode; 6 | } 7 | 8 | const If = ({ condition, children }: Props) => { 9 | return <>{Boolean(condition) && children}; 10 | }; 11 | 12 | export default If; 13 | -------------------------------------------------------------------------------- /electron/ipc/font.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "electron"; 2 | import { getFonts2 } from "font-list"; 3 | 4 | import { channel } from "./channel"; 5 | 6 | export function registerFontHandlers() { 7 | ipcMain.handle(channel.font.getFonts, async () => { 8 | return getFonts2(); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | tab_width = 2 12 | trim_trailing_whitespace = true 13 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { HashRouter } from "react-router"; 3 | 4 | import { App } from "./app"; 5 | 6 | const root = createRoot(document.getElementById("root") as Element); 7 | root.render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /src/pages/search/search-type.tsx: -------------------------------------------------------------------------------- 1 | export enum SearchType { 2 | Video = "video", 3 | User = "bili_user", 4 | } 5 | 6 | export const SearchTypeOptions = [ 7 | { 8 | label: "视频", 9 | value: SearchType.Video, 10 | }, 11 | { 12 | label: "用户", 13 | value: SearchType.User, 14 | }, 15 | ]; 16 | -------------------------------------------------------------------------------- /src/types/react-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.svg" { 4 | import type * as React from "react"; 5 | 6 | export const ReactComponent: React.FunctionComponent & { title?: string }>; 7 | 8 | const src: string; 9 | export default src; 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ 提需求 (Feature Request) 2 | description: 建议添加新功能 3 | labels: ["✨feature"] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: 需求详情 9 | description: 请描述你想要的功能是什么。 10 | placeholder: 我希望添加一个...功能,因为... 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": false, 4 | "semi": true, 5 | "jsxSingleQuote": false, 6 | "singleQuote": false, 7 | "printWidth": 120, 8 | "proseWrap": "never", 9 | "bracketSpacing": true, 10 | "bracketSameLine": false, 11 | "arrowParens": "avoid", 12 | "endOfLine": "auto", 13 | "plugins": ["prettier-plugin-tailwindcss"] 14 | } 15 | -------------------------------------------------------------------------------- /electron/ipc/cookie.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, session } from "electron"; 2 | 3 | import { channel } from "./channel"; 4 | 5 | export function registerCookieIpcHandlers() { 6 | ipcMain.handle(channel.cookie.get, async (_, key: string) => { 7 | const cookies = await session.defaultSession.cookies.get({ name: key, domain: ".bilibili.com" }); 8 | 9 | return cookies?.[0]?.value; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Biu 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/common/utils/number.ts: -------------------------------------------------------------------------------- 1 | const formatter = new Intl.NumberFormat("zh-CN", { 2 | notation: "compact", // 紧凑模式 3 | compactDisplay: "short", // 短格式 (万/亿) 4 | maximumFractionDigits: 2, // 保留几位小数 5 | }); 6 | 7 | export const formatNumber = (num: number | null | undefined) => { 8 | if (typeof num !== "number") { 9 | return num; 10 | } 11 | 12 | return formatter.format(num) as string; 13 | }; 14 | -------------------------------------------------------------------------------- /shared/path/index.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export const ELECTRON_OUT_DIRNAME = ".electron"; 4 | export const ELECTRON_ICON_DIRNAME = "icons"; 5 | export const ELECTRON_ICON_BASE_PATH = `${ELECTRON_OUT_DIRNAME}/${ELECTRON_ICON_DIRNAME}`; 6 | export const ELECTRON_OUT_DIR = path.resolve(process.cwd(), ELECTRON_OUT_DIRNAME); 7 | export const ICONS_DST_DIR = path.resolve(process.cwd(), ELECTRON_ICON_BASE_PATH); 8 | -------------------------------------------------------------------------------- /src/common/utils/file.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeFilename(filename: string): string { 2 | return ( 3 | filename 4 | .replace(/[<>:"|?*\\/]/g, "") 5 | // eslint-disable-next-line no-control-regex 6 | .replace(/[\x00-\x1f\x80-\x9f]/g, "") 7 | .replace(/^\.+/, "") 8 | .replace(/\.+$/, "") 9 | .replace(/\s+/g, " ") 10 | .trim() 11 | .substring(0, 200) 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/store/app-update.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface AppUpdateActions { 4 | setUpdate: (updateInfo: CheckAppUpdateResult) => void; 5 | } 6 | 7 | export const useAppUpdateStore = create(set => ({ 8 | isUpdateAvailable: false, 9 | setUpdate: updateInfo => 10 | set(state => ({ 11 | ...state, 12 | ...updateInfo, 13 | })), 14 | })); 15 | -------------------------------------------------------------------------------- /shared/settings/app-settings.ts: -------------------------------------------------------------------------------- 1 | export const defaultAppSettings: AppSettings = { 2 | autoStart: false, 3 | closeWindowOption: "hide", 4 | fontFamily: "system-ui", 5 | borderRadius: 8, 6 | downloadPath: "", 7 | backgroundColor: "#18181b", 8 | contentBackgroundColor: "#1f1f1f", 9 | primaryColor: "#17c964", 10 | audioQuality: "auto", 11 | hiddenMenuKeys: [], 12 | displayMode: "card", 13 | ffmpegPath: "", 14 | }; 15 | -------------------------------------------------------------------------------- /shared/types/app-setting.d.ts: -------------------------------------------------------------------------------- 1 | type AudioQuality = "auto" | "lossless" | "high" | "medium" | "low"; 2 | 3 | interface AppSettings { 4 | fontFamily: string; 5 | backgroundColor: string; 6 | contentBackgroundColor: string; 7 | primaryColor: string; 8 | borderRadius: number; 9 | downloadPath?: string; 10 | closeWindowOption: "hide" | "exit"; 11 | autoStart: boolean; 12 | audioQuality: AudioQuality; 13 | hiddenMenuKeys: string[]; 14 | displayMode: "card" | "list"; 15 | ffmpegPath?: string; 16 | } 17 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import path from "node:path"; 3 | import { defineConfig } from "vitest/config"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | environment: "node", 9 | setupFiles: [path.resolve(__dirname, "tests/setup.ts")], 10 | restoreMocks: true, 11 | mockReset: true, 12 | clearMocks: true, 13 | }, 14 | resolve: { 15 | alias: { 16 | "@": path.resolve(__dirname, "src"), 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/service/web-buvid.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export interface WebBuvidResponse { 4 | code: number; // 返回值 0:成功 -400:请求错误 -404:无视频 5 | message: string; // 错误信息 6 | ttl: number; // 1 7 | data: WebBuvidData; 8 | } 9 | 10 | export interface WebBuvidData { 11 | b_3: string; // buvid3 12 | b_4: string; // buvid4 13 | } 14 | 15 | /** 16 | * 获取buvid 17 | */ 18 | export const getWebBuvid = async () => { 19 | return axios.get("https://api.bilibili.com/x/frontend/finger/spi"); 20 | }; 21 | -------------------------------------------------------------------------------- /src/service/passport-login-web-key.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 获取密码登录公钥_web端响应类型 5 | */ 6 | export interface WebKeyResponse { 7 | code: number; // 返回值 0:成功 8 | message: string; // 错误信息 9 | ttl?: number; // 1 10 | data: { 11 | hash: string; // 盐值 12 | key: string; // RSA 公钥(PEM 格式) 13 | }; 14 | } 15 | 16 | /** 17 | * 获取密码登录所需的公钥与哈希 18 | */ 19 | export const getPassportLoginWebKey = () => { 20 | return passportRequest.get("/x/passport-login/web/key"); 21 | }; 22 | -------------------------------------------------------------------------------- /src/assets/icons/audio-download.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/video-download.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/layout/playbar/left/video-page-list/utils.ts: -------------------------------------------------------------------------------- 1 | import type { PlayData } from "@/store/play-list"; 2 | 3 | import { formatDuration } from "@/common/utils"; 4 | 5 | export const getDisplayTitle = (data: PlayData): string => { 6 | return data.pageTitle || data.title || ""; 7 | }; 8 | 9 | export const getDisplayCover = (data: PlayData): string | undefined => { 10 | return data.pageCover || data.cover || undefined; 11 | }; 12 | 13 | export const getDurationText = (data: PlayData): string => { 14 | return data.duration ? formatDuration(data.duration) : ""; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/empty/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { RiCloseCircleLine } from "@remixicon/react"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | interface Props { 7 | title?: React.ReactNode; 8 | className?: string; 9 | } 10 | 11 | const Empty = ({ title, className }: Props) => { 12 | return ( 13 |
14 | 15 | {title ?? "暂无内容"} 16 |
17 | ); 18 | }; 19 | 20 | export default Empty; 21 | -------------------------------------------------------------------------------- /src/pages/mini-player/play-state.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | import type { PlayMode } from "@/common/constants/audio"; 4 | 5 | interface State { 6 | isPlaying: boolean; 7 | isSingle: boolean; 8 | title?: string; 9 | cover?: string; 10 | currentTime: number; 11 | duration: number; 12 | playMode?: PlayMode; 13 | } 14 | 15 | interface Action { 16 | update: (state: State) => void; 17 | } 18 | 19 | export const usePlayState = create(set => ({ 20 | isPlaying: false, 21 | isSingle: false, 22 | currentTime: 0, 23 | duration: 0, 24 | update: state => set(state), 25 | })); 26 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "entry": ["src/index.tsx", "electron/main.ts", "electron/preload.ts", "plugins/rsbuild-plugin-electron.ts"], 4 | "project": ["src/**", "electron/**", "shared/**", "plugins/**"], 5 | "ignore": ["dist/**", ".electron/**"], 6 | "ignoreExportsUsedInFile": { 7 | "interface": true, 8 | "type": true 9 | }, 10 | "ignoreDependencies": ["@tailwindcss/postcss"], 11 | "ignoreFiles": ["dist/**", "public/**", ".electron/**"], 12 | "include": ["files", "dependencies", "exports"], 13 | "includeEntryExports": true, 14 | "tags": ["-lintignore"] 15 | } 16 | -------------------------------------------------------------------------------- /src/layout/side/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@heroui/react"; 2 | 3 | import ScrollContainer from "@/components/scroll-container"; 4 | 5 | import Collection from "./collection"; 6 | import DefaultMenus from "./default-menu"; 7 | import Logo from "./logo"; 8 | 9 | const SideNav = () => { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default SideNav; 22 | -------------------------------------------------------------------------------- /src/common/utils/str.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 移除文本中的html标签,用于搜索结果标题处理的场景 3 | */ 4 | export function stripHtml(htmlString: string) { 5 | if (!htmlString) return ""; 6 | 7 | if (/<[^>]+>/.test(htmlString)) { 8 | try { 9 | const doc = new DOMParser().parseFromString(htmlString, "text/html"); 10 | return doc.body.textContent || ""; 11 | } catch { 12 | let sanitized = htmlString; 13 | let prev; 14 | do { 15 | prev = sanitized; 16 | sanitized = sanitized.replace(/<[^>]+>/g, ""); 17 | } while (sanitized !== prev); 18 | return sanitized; 19 | } 20 | } 21 | 22 | return htmlString; 23 | } 24 | -------------------------------------------------------------------------------- /src/service/passport-login-web-qrcode-generate.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 二维码登录 - 申请二维码(web端)接口响应类型 5 | */ 6 | export interface QrcodeGenerateResponse { 7 | code: number; // 返回值 0:成功 8 | message: string; // 错误信息 9 | ttl: number; // 1 10 | data: { 11 | url: string; // 二维码内容 (登录页面 url) 12 | qrcode_key: string; // 扫码登录秘钥,恒为32字符 13 | }; 14 | } 15 | 16 | /** 17 | * 二维码登录 - 申请二维码(web端) 18 | * @returns 二维码信息,包含url和qrcode_key 19 | */ 20 | export const getPassportLoginWebQrcodeGenerate = () => { 21 | return passportRequest.get("/x/passport-login/web/qrcode/generate"); 22 | }; 23 | -------------------------------------------------------------------------------- /changelog.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": { 3 | "feat": { "title": "🚀 新功能", "semver": "minor" }, 4 | "perf": { "title": "🔥 功能优化", "semver": "patch" }, 5 | "fix": { "title": "🩹 修复问题", "semver": "patch" }, 6 | "style": { "title": "🎨 UI 调整" }, 7 | "refactor": { "title": "💅 Refactors", "semver": "patch" }, 8 | "docs": { "title": "📖 Documentation", "semver": "patch" }, 9 | "build": { "title": "📦 Build", "semver": "patch" }, 10 | "types": { "title": "🌊 Types", "semver": "patch" }, 11 | "chore": { "title": "🏡 Chore" }, 12 | "examples": { "title": "🏀 Examples" }, 13 | "test": { "title": "✅ Tests" }, 14 | "ci": { "title": "🤖 CI" } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/not-found/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNavigate } from "react-router"; 3 | 4 | import { useInterval } from "ahooks"; 5 | 6 | import Empty from "@/components/empty"; 7 | 8 | /** 9 | * 路由不匹配处理 10 | */ 11 | const NotFound: React.FC = () => { 12 | const navigate = useNavigate(); 13 | const [count, setCount] = useState(3); 14 | 15 | useInterval(() => setCount(count - 1), 1000); 16 | 17 | useEffect(() => { 18 | if (count === 0) { 19 | navigate("/"); 20 | } 21 | }, [count]); 22 | 23 | return ( 24 |
25 | 26 |
27 | ); 28 | }; 29 | 30 | export default NotFound; 31 | -------------------------------------------------------------------------------- /plugins/mac/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.cs.allow-jit 12 | 13 | com.apple.security.cs.allow-unsigned-executable-memory 14 | 15 | com.apple.security.cs.disable-library-validation 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/service/generic-country-list.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 获取国际冠字码_web端接口响应类型 5 | */ 6 | export interface CountryListResponse { 7 | code: number; // 返回值,0表示成功 8 | data: { 9 | common: CountryInfo[]; // 常用国家&地区 10 | others: CountryInfo[]; // 其他国家&地区 11 | }; 12 | } 13 | 14 | /** 15 | * 国家&地区信息 16 | */ 17 | export interface CountryInfo { 18 | id: number; // 国际代码值 19 | cname: string; // 国家&地区名 20 | country_id: string; // 国家&地区区号 21 | } 22 | 23 | /** 24 | * 获取国际冠字码_web端 25 | * @returns 国际冠字码列表 26 | */ 27 | export function getGenericCountryList(): Promise { 28 | return passportRequest.get("/web/generic/country/list"); 29 | } 30 | -------------------------------------------------------------------------------- /src/service/passport-login-captcha.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 获取验证码(web端)响应类型 5 | */ 6 | export interface PassportLoginCaptchaResponse { 7 | code: number; // 0:成功 8 | message: string; 9 | data: { 10 | type: string; // geetest 11 | token: string; 12 | geetest: { 13 | gt: string; 14 | challenge: string; 15 | }; 16 | }; 17 | } 18 | 19 | /** 20 | * 获取验证码(web端) 21 | * @param source 来源 22 | * @returns 验证码信息 23 | */ 24 | export const getPassportLoginCaptcha = (source: string = "main_web") => { 25 | return passportRequest.get("/x/passport-login/captcha", { 26 | params: { source }, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /shared/types/app-update.d.ts: -------------------------------------------------------------------------------- 1 | interface AppUpdateReleaseInfo { 2 | /** 最新版本 */ 3 | latestVersion?: string; 4 | /** html 字符串 */ 5 | releaseNotes?: string; 6 | } 7 | 8 | interface CheckAppUpdateResult extends AppUpdateReleaseInfo { 9 | isUpdateAvailable?: boolean; 10 | error?: string; 11 | } 12 | 13 | interface DownloadAppProgressInfo { 14 | total: number; 15 | delta: number; 16 | transferred: number; 17 | percent: number; 18 | bytesPerSecond: number; 19 | } 20 | 21 | type DownloadAppUpdateStatus = "downloading" | "downloaded" | "error"; 22 | 23 | interface DownloadAppMessage { 24 | status: DownloadAppUpdateStatus; 25 | processInfo?: DownloadAppProgressInfo; 26 | error?: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/image-card/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardFooter, Skeleton } from "@heroui/react"; 2 | 3 | export type SkeletonProps = { 4 | coverHeight?: number; 5 | }; 6 | 7 | export default function CardSkeleton({ coverHeight = 188 }: SkeletonProps) { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/common/utils/time.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import momentDurationFormatSetup from "moment-duration-format"; 3 | 4 | momentDurationFormatSetup(moment); 5 | 6 | export function formatDuration(seconds: number) { 7 | const dur = moment.duration(seconds, "seconds"); 8 | 9 | if (seconds >= 3600) { 10 | // 超过 60 分钟 → hh:mm:ss 11 | return dur.format("hh:mm:ss", { trim: false }); 12 | } else { 13 | // 小于 60 秒 → ss 14 | return dur.format("mm:ss", { trim: false }); 15 | } 16 | } 17 | 18 | export const formatSecondsToDate = (s?: number) => (s ? moment.unix(s).format("YYYY-MM-DD") : ""); 19 | 20 | export const formatMillisecond = (s?: number) => (s ? moment(s).format("YYYY-MM-DD") : ""); 21 | -------------------------------------------------------------------------------- /src/service/history-toview-clear.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 清空稍后再看视频列表 - 请求参数 5 | * POST /x/v2/history/toview/clear 6 | */ 7 | export interface HistoryToViewClearParams { 8 | /** CSRF Token(bili_jct) */ 9 | csrf: string; 10 | } 11 | 12 | /** 顶层响应 */ 13 | export interface HistoryToViewClearResponse { 14 | code: number; // 0 成功 -101 未登录 -111 csrf 校验失败 15 | message: string; // 错误信息 16 | ttl: number; // 1 17 | } 18 | 19 | /** 20 | * 清空稍后再看视频列表 21 | */ 22 | export async function postHistoryToViewClear(data: HistoryToViewClearParams): Promise { 23 | return apiRequest.post("/x/v2/history/toview/clear", data); 24 | } 25 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('stylelint').Config} */ 2 | export default { 3 | extends: ["stylelint-config-standard"], 4 | rules: { 5 | "selector-class-pattern": null, 6 | "property-no-vendor-prefix": null, 7 | "function-no-unknown": null, 8 | "import-notation": null, 9 | "selector-pseudo-class-no-unknown": [ 10 | true, 11 | { 12 | ignorePseudoClasses: ["global"], 13 | }, 14 | ], 15 | "block-no-empty": true, 16 | "color-hex-length": "short", 17 | "at-rule-no-unknown": [ 18 | true, 19 | { 20 | ignoreAtRules: ["extends", "tailwind", "plugin", "source", "custom-variant", "utility"], 21 | }, 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/service/passport-login-web-country.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 获取国际冠字码_web端接口响应类型 5 | */ 6 | export interface CountryListResponse { 7 | code: number; // 返回值,0表示成功 8 | data: { 9 | default: CountryInfo; // 常用国家&地区 10 | list: CountryInfo[]; // 所有国家&地区 11 | }; 12 | } 13 | 14 | /** 15 | * 国家&地区信息 16 | */ 17 | export interface CountryInfo { 18 | id: number; // 国际代码值 19 | cname: string; // 国家&地区名 20 | country_code: string; // 国家&地区区号 21 | } 22 | 23 | /** 24 | * 获取默认地区冠字号 25 | * @returns 国际冠字码列表 26 | */ 27 | export function getPassportLoginDefaultCountry(): Promise { 28 | return passportRequest.get("/x/passport-login/web/country"); 29 | } 30 | -------------------------------------------------------------------------------- /electron/ipc/mini-player.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "electron"; 2 | 3 | import type { IpcHandlerProps } from "./types"; 4 | 5 | import { createMiniPlayer, destroyMiniPlayer } from "../mini-player"; 6 | import { channel } from "./channel"; 7 | 8 | export const registerMiniPlayerHandlers = ({ getMainWindow }: IpcHandlerProps) => { 9 | ipcMain.handle(channel.window.switchToMini, () => { 10 | const mainWindow = getMainWindow?.(); 11 | if (mainWindow) { 12 | mainWindow.hide(); 13 | } 14 | 15 | createMiniPlayer(); 16 | }); 17 | 18 | ipcMain.handle(channel.window.switchToMain, () => { 19 | destroyMiniPlayer(); 20 | const mainWindow = getMainWindow?.(); 21 | mainWindow?.show(); 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /electron/network/web-buvid.ts: -------------------------------------------------------------------------------- 1 | import { UserAgent } from "./user-agent"; 2 | 3 | export interface WebBuvidResponse { 4 | code: number; // 返回值 0:成功 -400:请求错误 -404:无视频 5 | message: string; // 错误信息 6 | ttl: number; // 1 7 | data: WebBuvidData; 8 | } 9 | 10 | export interface WebBuvidData { 11 | b_3: string; // buvid3 12 | b_4: string; // buvid4 13 | } 14 | 15 | /** 16 | * 获取buvid 17 | */ 18 | export const getWebBuvid = async () => { 19 | const response = await fetch("https://api.bilibili.com/x/frontend/finger/spi_v2", { 20 | method: "GET", 21 | headers: { 22 | "User-Agent": UserAgent, 23 | }, 24 | }); 25 | 26 | const data = (await response.json()) as WebBuvidResponse; 27 | 28 | return data.data; 29 | }; 30 | -------------------------------------------------------------------------------- /src/service/fav-season-fav.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 收藏视频合集 5 | */ 6 | export interface FavSeasonFavRequestParams { 7 | /** 目标收藏夹 mdid 列表,逗号分隔 */ 8 | season_id: number; 9 | /** web */ 10 | platform: string; 11 | } 12 | 13 | /** 14 | * 收藏视频合集 - 响应类型 15 | */ 16 | export interface FavSeasonFavResponse { 17 | /** 返回值 0:成功 */ 18 | code: number; 19 | /** 错误信息,成功为 "0" */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体,成功为 SUCCESS */ 24 | data: string; 25 | } 26 | 27 | /** 28 | * 收藏视频合集 29 | */ 30 | export function postFavSeasonFav(data: FavSeasonFavRequestParams) { 31 | return apiRequest.post("/x/v3/fav/season/fav", data, { useCSRF: true, useFormData: true }); 32 | } 33 | -------------------------------------------------------------------------------- /src/service/fav-folder-fav.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 收藏视频收藏夹 5 | */ 6 | export interface FavFolderFavRequestParams { 7 | /** 目标收藏夹 mdid 列表,逗号分隔 */ 8 | media_id: number; 9 | /** web */ 10 | platform: string; 11 | } 12 | 13 | /** 14 | * 收藏视频收藏夹 - 响应类型 15 | */ 16 | export interface FavFolderFavResponse { 17 | /** 返回值 0:成功 */ 18 | code: number; 19 | /** 错误信息,成功为 "0" */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体,成功为 SUCCESS */ 24 | data: string; 25 | } 26 | 27 | /** 28 | * 取消收藏视频合集 29 | */ 30 | export function postFavFolderFav(data: FavFolderFavRequestParams) { 31 | return apiRequest.post("/x/v3/fav/folder/fav", data, { useCSRF: true, useFormData: true }); 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "jsx": "preserve", 5 | "lib": ["ESNext", "dom"], 6 | "baseUrl": ".", 7 | "module": "preserve", 8 | "moduleResolution": "bundler", 9 | "paths": { 10 | "@/*": ["./src/*"], 11 | "@shared/*": ["./shared/*"] 12 | }, 13 | "resolveJsonModule": true, 14 | "noImplicitAny": false, 15 | "types": ["node"], 16 | "allowJs": true, 17 | "strict": true, 18 | "noEmit": true, 19 | "verbatimModuleSyntax": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "skipLibCheck": true 22 | }, 23 | "include": ["src", "shared", "plugins", "electron", "tests", "rsbuild.config.ts"], 24 | "exclude": ["node_modules", "dist", "public", ".electron"] 25 | } 26 | -------------------------------------------------------------------------------- /src/service/fav-folder-unfav.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 取消收藏视频收藏夹 5 | */ 6 | export interface FavFolderUnfavRequestParams { 7 | /** 目标收藏夹 mdid 列表,逗号分隔 */ 8 | media_id: number; 9 | /** web */ 10 | platform: string; 11 | } 12 | 13 | /** 14 | * 取消收藏收藏夹 - 响应类型 15 | */ 16 | export interface FavFolderUnfavResponse { 17 | /** 返回值 0:成功 */ 18 | code: number; 19 | /** 错误信息,成功为 "0" */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体,成功为 SUCCESS */ 24 | data: string; 25 | } 26 | 27 | /** 28 | * 取消收藏视频合集 29 | */ 30 | export function postFavFolderUnfav(data: FavFolderUnfavRequestParams) { 31 | return apiRequest.post("/x/v3/fav/folder/unfav", data, { useCSRF: true, useFormData: true }); 32 | } 33 | -------------------------------------------------------------------------------- /src/service/fav-season-unfav.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 取消收藏视频合集 5 | */ 6 | export interface FavSeasonUnfavRequestParams { 7 | /** 目标收藏夹 mdid 列表,逗号分隔 */ 8 | season_id: number; 9 | /** web */ 10 | platform: string; 11 | } 12 | 13 | /** 14 | * 删除收藏夹 - 响应类型 15 | */ 16 | export interface FavSeasonUnfavResponse { 17 | /** 返回值 0:成功 */ 18 | code: number; 19 | /** 错误信息,成功为 "0" */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体,成功为 SUCCESS */ 24 | data: string; 25 | } 26 | 27 | /** 28 | * 取消收藏视频合集 29 | */ 30 | export function postFavSeasonUnfav(data: FavSeasonUnfavRequestParams) { 31 | return apiRequest.post("/x/v3/fav/season/unfav", data, { useCSRF: true, useFormData: true }); 32 | } 33 | -------------------------------------------------------------------------------- /src/common/utils/url.ts: -------------------------------------------------------------------------------- 1 | import type { PlayData } from "@/store/play-list"; 2 | 3 | export const getUrlParams = (url: string) => { 4 | const urlParams = new URLSearchParams(url.split("?")[1]); 5 | return Object.fromEntries(urlParams.entries()); 6 | }; 7 | 8 | export const formatUrlProtocal = (url: string) => { 9 | if (url && !url.startsWith("http")) { 10 | return `https:${url}`; 11 | } 12 | 13 | return url; 14 | }; 15 | 16 | export const getBiliVideoLink = (data: PlayData) => { 17 | return `https://www.bilibili.com/${data?.type === "mv" ? `video/${data?.bvid}${(data.pageIndex ?? 0) > 1 ? `?p=${data.pageIndex}` : ""}` : `audio/au${data?.sid}`}`; 18 | }; 19 | 20 | export const openBiliVideoLink = (data: PlayData) => { 21 | window.electron.openExternal(getBiliVideoLink(data)); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/menu/menu-group.tsx: -------------------------------------------------------------------------------- 1 | import MenuItem, { type MenuItemProps } from "../../components/menu/menu-item"; 2 | 3 | interface Props { 4 | title: React.ReactNode; 5 | titleExtra?: React.ReactNode; 6 | itemClassName?: string; 7 | items: MenuItemProps[]; 8 | } 9 | 10 | const MenuGroup = ({ title, titleExtra, itemClassName, items }: Props) => { 11 | return ( 12 | <> 13 |
14 | {title} 15 | {titleExtra} 16 |
17 |
18 | {items.map(item => ( 19 | 20 | ))} 21 |
22 | 23 | ); 24 | }; 25 | 26 | export default MenuGroup; 27 | -------------------------------------------------------------------------------- /src/layout/navbar/index.tsx: -------------------------------------------------------------------------------- 1 | import Navigation from "./navigation"; 2 | import Search from "./search"; 3 | import UserCard from "./user"; 4 | import WindowAction from "./window-action"; 5 | 6 | const platform = window.electron.getPlatform(); 7 | 8 | const LayoutNavbar = () => { 9 | return ( 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 | {["linux", "windows"].includes(platform) && } 18 |
19 |
20 | ); 21 | }; 22 | 23 | export default LayoutNavbar; 24 | -------------------------------------------------------------------------------- /src/layout/playbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { Card } from "@heroui/react"; 4 | 5 | import { usePlayList } from "@/store/play-list"; 6 | 7 | import Center from "./center"; 8 | import Left from "./left"; 9 | import Right from "./right"; 10 | 11 | /** 12 | * 播放任务栏 13 | */ 14 | function PlayBar() { 15 | const playId = usePlayList(s => s.playId); 16 | const init = usePlayList(s => s.init); 17 | 18 | useEffect(() => { 19 | init(); 20 | }, [init]); 21 | 22 | return ( 23 | 24 |
{Boolean(playId) && }
25 |
26 | 27 | 28 | ); 29 | } 30 | 31 | export default PlayBar; 32 | -------------------------------------------------------------------------------- /src/components/typography/index.tsx: -------------------------------------------------------------------------------- 1 | import ScrollContainer from "../scroll-container"; 2 | 3 | interface Props { 4 | content: string; 5 | } 6 | 7 | const Typography = ({ content }: Props) => { 8 | const handleLinkClick: React.MouseEventHandler = e => { 9 | const target = (e.target as Element)?.closest("a"); 10 | 11 | if (target && target.href) { 12 | e.preventDefault(); // 阻止默认行为(防止在当前窗口跳转) 13 | window.electron.openExternal(target.href); // 调用系统浏览器打开 14 | } 15 | }; 16 | 17 | return ( 18 | 19 |
24 | 25 | ); 26 | }; 27 | 28 | export default Typography; 29 | -------------------------------------------------------------------------------- /src/service/history-toview-del.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 删除稍后再看视频 - 请求参数 5 | * POST /x/v2/history/toview/del 6 | */ 7 | export interface HistoryToViewDelParams { 8 | /** 是否删除所有已观看的视频,默认 false */ 9 | viewed?: boolean; 10 | /** 删除的目标记录的 avid,可选 */ 11 | aid?: number; 12 | } 13 | 14 | /** 顶层响应 */ 15 | export interface HistoryToViewDelResponse { 16 | code: number; // 0 成功 -101 未登录 -111 csrf 校验失败 -400 请求错误 17 | message: string; // 错误信息 18 | ttl: number; // 1 19 | } 20 | 21 | /** 22 | * 删除稍后再看视频 23 | */ 24 | export async function postHistoryToViewDel(data: HistoryToViewDelParams): Promise { 25 | return apiRequest.post("/x/v2/history/toview/del", data, { 26 | useFormData: true, 27 | useCSRF: true, 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/service/web-interface-archive-desc.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 获取视频简介 - 请求参数 5 | */ 6 | export interface WebInterfaceArchiveDescRequestParams { 7 | aid?: number; // 稿件avid 8 | bvid?: string; // 稿件bvid 9 | } 10 | 11 | /** 12 | * 获取视频简介 - 响应类型 13 | */ 14 | export interface WebInterfaceArchiveDescResponse { 15 | code: number; // 返回值 0:成功 -400:请求错误 -403:权限不足 -404:无视频 16 | message: string; // 错误信息 17 | ttl: number; // 1 18 | data: string; // 视频简介 19 | } 20 | 21 | /** 22 | * 获取视频简介 23 | * @param params 请求参数 24 | * @returns Promise 25 | */ 26 | export const getWebInterfaceArchiveDesc = (params: WebInterfaceArchiveDescRequestParams) => { 27 | return apiRequest.get("/x/web-interface/archive/desc", { params }); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/async-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { Button as HeroButton, type ButtonProps, type PressEvent } from "@heroui/react"; 4 | 5 | interface Props extends ButtonProps { 6 | onPress?: (e: PressEvent) => void | Promise; 7 | } 8 | 9 | const Button = ({ ref, onPress, ...props }: Props & { ref?: React.RefObject }) => { 10 | const [loading, setLoading] = useState(false); 11 | 12 | const handlePress = (e: PressEvent) => { 13 | const result = onPress?.(e); 14 | if (result instanceof Promise) { 15 | setLoading(true); 16 | result.finally(() => { 17 | setLoading(false); 18 | }); 19 | } 20 | }; 21 | 22 | return ; 23 | }; 24 | 25 | export default Button; 26 | -------------------------------------------------------------------------------- /src/components/scroll-container/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | OverlayScrollbarsComponent, 5 | type OverlayScrollbarsComponentProps, 6 | type OverlayScrollbarsComponentRef, 7 | } from "overlayscrollbars-react"; 8 | 9 | const ScrollContainer = ({ 10 | ref, 11 | options, 12 | children, 13 | ...props 14 | }: OverlayScrollbarsComponentProps & { ref?: React.RefObject }) => { 15 | return ( 16 | 21 | {children} 22 | 23 | ); 24 | }; 25 | 26 | export default ScrollContainer; 27 | export type ScrollRefObject = OverlayScrollbarsComponentRef<"div">; 28 | -------------------------------------------------------------------------------- /src/service/fav-folder-del.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 删除收藏夹 - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要 csrf)或 APP 7 | */ 8 | export interface FavFolderDelRequestParams { 9 | /** 目标收藏夹 mdid 列表,逗号分隔 */ 10 | media_ids: string; 11 | } 12 | 13 | /** 14 | * 删除收藏夹 - 响应类型 15 | */ 16 | export interface FavFolderDelResponse { 17 | /** 返回值 0:成功 */ 18 | code: number; 19 | /** 错误信息,成功为 "0" */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体,成功为 0 */ 24 | data: number; 25 | } 26 | 27 | /** 28 | * 删除收藏夹 29 | */ 30 | export function postFavFolderDel(data: FavFolderDelRequestParams): Promise { 31 | return apiRequest.post("/x/v3/fav/folder/del", data, { useCSRF: true, useFormData: true }); 32 | } 33 | -------------------------------------------------------------------------------- /electron/ipc/index.ts: -------------------------------------------------------------------------------- 1 | import type { IpcHandlerProps } from "./types"; 2 | 3 | import { registerAppHandlers } from "./app"; 4 | import { registerCookieIpcHandlers } from "./cookie"; 5 | import { registerDialogHandlers } from "./dialog"; 6 | import { registerDownloadHandlers } from "./download"; 7 | import { registerFontHandlers } from "./font"; 8 | import { registerMiniPlayerHandlers } from "./mini-player"; 9 | import { registerStoreHandlers } from "./store"; 10 | import { registerWindowHandlers } from "./window"; 11 | 12 | export function registerIpcHandlers(props: IpcHandlerProps) { 13 | registerStoreHandlers(); 14 | registerDialogHandlers(); 15 | registerFontHandlers(); 16 | registerDownloadHandlers(props); 17 | registerAppHandlers(); 18 | registerCookieIpcHandlers(); 19 | registerWindowHandlers(); 20 | registerMiniPlayerHandlers(props); 21 | } 22 | -------------------------------------------------------------------------------- /src/service/music-hot-rank.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | interface Params { 4 | /** web热榜为2 */ 5 | plat: number; 6 | /** 333.1351 */ 7 | web_location: string; 8 | } 9 | 10 | export interface Data { 11 | music_title: string; 12 | music_id: string; 13 | music_corner: string; 14 | cid: string; 15 | jump_url: string; 16 | author: string; 17 | bvid: string; 18 | album: string; 19 | aid: string; 20 | id: number; 21 | cover: string; 22 | total_vv: number; 23 | wish_count: number; 24 | source: string; 25 | } 26 | 27 | export interface Response { 28 | code: number; // 返回值,0表示成功 29 | message: string; // 错误信息 30 | data: { 31 | list: Data[]; 32 | }; 33 | } 34 | 35 | export const getMusicHotRank = (params: Params) => { 36 | return apiRequest.get("/x/centralization/interface/music/hot/rank", { params }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/layout/navbar/navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLocation, useNavigate } from "react-router"; 3 | 4 | import { Button } from "@heroui/react"; 5 | import { RiArrowLeftSLine } from "@remixicon/react"; 6 | 7 | const Navigation: React.FC = () => { 8 | const navigate = useNavigate(); 9 | // subscribe to location changes to re-render and reflect history state updates 10 | useLocation(); 11 | 12 | const canGoBack = (window.history?.state?.idx ?? 0) > 0; 13 | 14 | return ( 15 | 25 | ); 26 | }; 27 | 28 | export default Navigation; 29 | -------------------------------------------------------------------------------- /src/service/request/axios.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import axios from 'axios' 3 | 4 | declare module 'axios' { 5 | export interface AxiosInstance { 6 | request (config: AxiosRequestConfig): Promise; 7 | get(url: string, config?: AxiosRequestConfig): Promise; 8 | delete(url: string, config?: AxiosRequestConfig): Promise; 9 | head(url: string, config?: AxiosRequestConfig): Promise; 10 | post(url: string, data?: any, config?: AxiosRequestConfig): Promise; 11 | put(url: string, data?: any, config?: AxiosRequestConfig): Promise; 12 | patch(url: string, data?: any, config?: AxiosRequestConfig): Promise; 13 | } 14 | 15 | export interface AxiosRequestConfig { 16 | useFormData?: boolean; 17 | useWbi?: boolean; 18 | useCSRF?: boolean; 19 | skipRefreshCheck?: boolean; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /electron/mac/dock.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, Menu } from "electron"; 2 | 3 | import { channel } from "../ipc/channel"; 4 | 5 | export function setupMacDock(win: BrowserWindow) { 6 | const setDockMenu = (isPlaying: boolean) => { 7 | const dockMenu = Menu.buildFromTemplate([ 8 | { 9 | label: isPlaying ? "暂停" : "播放", 10 | click: () => win.webContents.send(channel.player.toggle), 11 | }, 12 | { 13 | label: "上一首", 14 | click: () => win.webContents.send(channel.player.prev), 15 | }, 16 | { 17 | label: "下一首", 18 | click: () => win.webContents.send(channel.player.next), 19 | }, 20 | ]); 21 | app.dock?.setMenu(dockMenu); 22 | }; 23 | 24 | // 初始化 25 | setDockMenu(false); 26 | 27 | // 监听播放状态 28 | ipcMain.on(channel.player.state, (_, isPlaying) => { 29 | setDockMenu(!!isPlaying); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /electron/ipc/api/dash-url.ts: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | 3 | import type { PlayerPlayurlRequestParams, PlayerPlayurlResponse } from "./types"; 4 | 5 | import { getCookieString } from "../../network/cookie"; 6 | import { UserAgent } from "../../network/user-agent"; 7 | import { encodeParamsWbi } from "./wbi"; 8 | 9 | export const getDashurl = async (params: PlayerPlayurlRequestParams) => { 10 | const cookie = await getCookieString(); 11 | const wbiParams = await encodeParamsWbi(params, cookie); 12 | 13 | const response = await got.get("https://api.bilibili.com/x/player/wbi/playurl", { 14 | searchParams: wbiParams, 15 | headers: { 16 | Cookie: cookie, 17 | Referer: "https://www.bilibili.com/", 18 | Origin: "https://www.bilibili.com", 19 | "User-Agent": UserAgent, 20 | }, 21 | responseType: "json", 22 | }); 23 | 24 | return response.body as PlayerPlayurlResponse; 25 | }; 26 | -------------------------------------------------------------------------------- /src/layout/playbar/right/play-mode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Button } from "@heroui/react"; 4 | 5 | import { getPlayModeList } from "@/common/constants/audio"; 6 | import { usePlayList } from "@/store/play-list"; 7 | 8 | import { PlayBarIconSize } from "../constants"; 9 | 10 | const PlayModeList = getPlayModeList(PlayBarIconSize.SideIconSize); 11 | 12 | const PlayModeSwitch = () => { 13 | const playMode = usePlayList(s => s.playMode); 14 | const togglePlayMode = usePlayList(s => s.togglePlayMode); 15 | 16 | return ( 17 | 27 | ); 28 | }; 29 | 30 | export default PlayModeSwitch; 31 | -------------------------------------------------------------------------------- /src/service/history-toview-add.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 视频添加稍后再看 - 请求参数 5 | * POST /x/v2/history/toview/add 6 | */ 7 | export interface HistoryToViewAddParams { 8 | /** 稿件 avid,与 bvid 任选一个 */ 9 | aid?: number; 10 | /** 稿件 bvid,与 aid 任选一个 */ 11 | bvid?: string; 12 | } 13 | 14 | /** 15 | * 视频添加稍后再看 - 顶层响应 16 | */ 17 | export interface HistoryToViewAddResponse { 18 | /** 返回值:0 成功;-101 未登录;-111 csrf 校验失败;-400 请求错误;90001 列表已满;90003 稿件已删除 */ 19 | code: number; 20 | /** 错误信息(默认为 "0") */ 21 | message: string; 22 | /** 固定为 1 */ 23 | ttl: number; 24 | } 25 | 26 | /** 27 | * 视频添加稍后再看 28 | */ 29 | export async function postHistoryToViewAdd(data: HistoryToViewAddParams): Promise { 30 | return apiRequest.post("/x/v2/history/toview/add", data, { 31 | useFormData: true, 32 | useCSRF: true, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/assets/icons/audio-animation.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /electron/ipc/download/types.ts: -------------------------------------------------------------------------------- 1 | export interface FullMediaDownloadTask extends MediaDownloadTask { 2 | /** 音频url(一般2小时过期,需要重新获取) */ 3 | audioUrl?: string; 4 | /** 音频编码格式 */ 5 | audioCodecs?: string; 6 | /** 视频url(一般2小时过期,需要重新获取) */ 7 | videoUrl?: string; 8 | /** 视频分辨率 */ 9 | videoResolution?: string; 10 | /** 视频帧率 */ 11 | videoFrameRate?: string; 12 | /** 文件名 */ 13 | fileName?: string; 14 | /** 音频临时文件路径 */ 15 | audioTempPath?: string; 16 | /** 视频临时文件路径 */ 17 | videoTempPath?: string; 18 | /** 保存路径 */ 19 | savePath?: string; 20 | /** 已下载字节数 */ 21 | downloadedBytes?: number; 22 | /** 下载分块信息 */ 23 | chunks?: MediaDownloadChunk[]; 24 | } 25 | 26 | export interface MediaDownloadChunk { 27 | type: MediaDownloadOutputFileType; 28 | /** 分块文件名 */ 29 | name: string; 30 | /** 分块起始字节位置 */ 31 | start: number; 32 | /** 分块结束字节位置 */ 33 | end: number; 34 | /** 分块是否下载完成 */ 35 | done: boolean; 36 | } 37 | -------------------------------------------------------------------------------- /src/pages/video-collection/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { useSearchParams } from "react-router"; 3 | 4 | import { CollectionType } from "@/common/constants/collection"; 5 | import ScrollContainer from "@/components/scroll-container"; 6 | 7 | import Favorites from "./favorites"; 8 | import VideoCollectionInfo from "./video-series"; 9 | 10 | const Folder = () => { 11 | const [searchParams] = useSearchParams(); 12 | 13 | const collectionType = useMemo( 14 | () => Number(searchParams.get("type") || CollectionType.Favorite) as CollectionType, 15 | [searchParams], 16 | ); 17 | 18 | return ( 19 | 20 |
21 | {collectionType === CollectionType.Favorite && } 22 | {collectionType === CollectionType.VideoSeries && } 23 |
24 |
25 | ); 26 | }; 27 | 28 | export default Folder; 29 | -------------------------------------------------------------------------------- /src/service/passport-login-web-qrcode-poll.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 二维码登录 - 扫码登录(web端)请求参数类型 5 | */ 6 | export interface QrcodePollRequestParams { 7 | qrcode_key: string; // 扫码登录秘钥 8 | } 9 | 10 | /** 11 | * 二维码登录 - 扫码登录(web端)响应类型 12 | */ 13 | export interface QrcodePollResponse { 14 | code: number; // 返回值 0:成功 15 | message: string; // 错误信息 16 | data: { 17 | url: string; // 游戏分站跨域登录 url,未登录为空 18 | refresh_token: string; // 刷新refresh_token,未登录为空 19 | timestamp: number; // 登录时间,未登录为0,时间戳单位为毫秒 20 | code: number; // 0:扫码登录成功 86038:二维码已失效 86090:二维码已扫码未确认 86101:未扫码 21 | message: string; // 扫码状态信息 22 | }; 23 | } 24 | 25 | /** 26 | * 二维码登录 - 扫码登录状态查询(web端) 27 | * @param params 包含qrcode_key的请求参数 28 | * @returns 扫码状态信息 29 | */ 30 | export const getPassportLoginWebQrcodePoll = (params: QrcodePollRequestParams) => { 31 | return passportRequest.get("/x/passport-login/web/qrcode/poll", { params }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin './hero.ts'; 4 | @plugin "@tailwindcss/typography"; 5 | 6 | @source '../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'; 7 | 8 | @custom-variant dark (&:is(.dark *)); 9 | 10 | @layer base { 11 | h1 { 12 | font-size: var(--text-2xl); 13 | } 14 | 15 | h2 { 16 | font-size: var(--text-xl); 17 | } 18 | 19 | h3 { 20 | font-size: var(--text-lg); 21 | } 22 | } 23 | 24 | @utility window-drag { 25 | app-region: drag; 26 | } 27 | 28 | @utility window-no-drag { 29 | app-region: no-drag; 30 | } 31 | 32 | :root { 33 | text-autospace: normal; 34 | } 35 | 36 | html, body, #root { 37 | height: 100%; 38 | overflow: hidden; 39 | } 40 | 41 | .suggest_high_light,.keyword { 42 | font-style: normal; 43 | color: #F54180; 44 | } 45 | 46 | .os-scrollbar { 47 | --os-size: 10px; 48 | --os-handle-border-radius: 2px; 49 | --os-handle-bg: #595959aa; 50 | --os-handle-bg-hover: #888; 51 | 52 | padding: 0; 53 | } 54 | -------------------------------------------------------------------------------- /src/service/passport-login-web-sms-send.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 发送短信验证码_web端请求参数 5 | */ 6 | export interface WebSmsSendRequestParams { 7 | cid: number; // 国际冠字码 8 | tel: number; // 手机号码 9 | source: string; // 登录来源,main_web:独立登录页 main_mini:小窗登录 10 | token: string; // 登录 API token 11 | challenge: string; // 极验 challenge 12 | validate: string; // 极验 result 13 | seccode: string; // 极验 result +|jordan 14 | } 15 | 16 | /** 17 | * 发送短信验证码_web端响应类型 18 | */ 19 | export interface WebSmsSendResponse { 20 | code: number; // 返回值,0表示成功 21 | message: string; // 错误信息 22 | data: { 23 | captcha_key: string; // 短信登录 token 24 | }; 25 | } 26 | 27 | /** 28 | * 发送短信验证码_web端 29 | * @param params 请求参数 30 | * @returns 短信验证码发送结果 31 | */ 32 | export function passportLoginWebSmsSend(params: WebSmsSendRequestParams): Promise { 33 | return passportRequest.post("/x/passport-login/web/sms/send", params, { 34 | useFormData: true, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "files.eol": "auto", 4 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit", 7 | "source.fixAll.stylelint": "explicit" 8 | }, 9 | "[json]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "css.validate": false, 13 | "less.validate": false, 14 | "scss.validate": false, 15 | "stylelint.validate": ["css", "less", "postcss", "scss"], 16 | "files.associations": { 17 | "*.css": "tailwindcss" 18 | }, 19 | "editor.quickSuggestions": { 20 | "strings": true 21 | }, 22 | "tailwindCSS.includeLanguages": { 23 | "typescript": "javascript", 24 | "typescriptreact": "javascript" 25 | }, 26 | "tailwindCSS.classAttributes": ["class", "className", "ngClass", "classNames", ".*ClassName", ".*Classes"], 27 | "[typescript]": { 28 | "editor.defaultFormatter": "vscode.typescript-language-features" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/service/fav-resource-clean.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 清空所有失效内容 - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要 csrf)或 APP 7 | */ 8 | export interface FavResourceCleanRequestParams { 9 | /** 目标收藏夹 id */ 10 | media_id: number; 11 | /** CSRF Token(bili_jct),Cookie 方式必要 */ 12 | csrf: string; 13 | } 14 | 15 | /** 16 | * 清空所有失效内容 - 响应类型 17 | */ 18 | export interface FavResourceCleanResponse { 19 | /** 返回值 0:成功 */ 20 | code: number; 21 | /** 错误信息,默认为 "0" */ 22 | message: string; 23 | /** 固定为 1 */ 24 | ttl: number; 25 | /** 信息本体,成功为 0 */ 26 | data: number; 27 | } 28 | 29 | /** 30 | * 清空所有失效内容 31 | */ 32 | export function postFavResourceClean(params: FavResourceCleanRequestParams): Promise { 33 | const form = new URLSearchParams(); 34 | form.set("media_id", String(params.media_id)); 35 | form.set("csrf", params.csrf); 36 | return apiRequest.post("/x/v3/fav/resource/clean", form); 37 | } 38 | -------------------------------------------------------------------------------- /screenshots/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/layout/side/default-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { DefaultMenuList } from "@/common/constants/menus"; 4 | import MenuItem from "@/components/menu/menu-item"; 5 | import { useSettings } from "@/store/settings"; 6 | import { useUser } from "@/store/user"; 7 | 8 | const DefaultMenus = () => { 9 | const user = useUser(state => state.user); 10 | const hiddenMenuKeys = useSettings(state => state.hiddenMenuKeys); 11 | 12 | return ( 13 |
14 | {DefaultMenuList.filter(item => (item.needLogin ? user?.isLogin : true)) 15 | .filter(item => !hiddenMenuKeys.includes(item.href)) 16 | .map(item => { 17 | return ( 18 | 25 | ); 26 | })} 27 |
28 | ); 29 | }; 30 | 31 | export default DefaultMenus; 32 | -------------------------------------------------------------------------------- /src/hero.ts: -------------------------------------------------------------------------------- 1 | import { heroui } from "@heroui/react"; 2 | 3 | export default heroui({ 4 | themes: { 5 | dark: { 6 | colors: { 7 | primary: { 8 | "50": "#09411d", 9 | "100": "#0e662e", 10 | "200": "#148c3e", 11 | "300": "#19b14f", 12 | "400": "#1ed760", 13 | "500": "#45de7c", 14 | "600": "#6de598", 15 | "700": "#94ecb3", 16 | "800": "#bcf3cf", 17 | "900": "#e3faeb", 18 | foreground: "#000", 19 | DEFAULT: "#1ed760", 20 | }, 21 | secondary: { 22 | "50": "#324839", 23 | "100": "#50725b", 24 | "200": "#6d9b7c", 25 | "300": "#8bc59e", 26 | "400": "#a8efbf", 27 | "500": "#b7f2ca", 28 | "600": "#c6f5d5", 29 | "700": "#d6f7e1", 30 | "800": "#e5faec", 31 | "900": "#f4fdf7", 32 | foreground: "#000", 33 | DEFAULT: "#a8efbf", 34 | }, 35 | focus: "#1E90FF", 36 | }, 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /electron/ipc/api/audio-stream-url.ts: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | 3 | import type { AudioStreamUrlResponse } from "./types"; 4 | 5 | import { getCookieString } from "../../network/cookie"; 6 | import { UserAgent } from "../../network/user-agent"; 7 | import { userStore } from "../../store"; 8 | 9 | export const getAudioWebStreamUrl = async (songid: string | number) => { 10 | const cookie = await getCookieString(); 11 | const mid = userStore.get("mid") || ""; 12 | const vipStatus = userStore.get("vipStatus") || 2; 13 | 14 | const response = await got.get("https://api.bilibili.com/audio/music-service-c/url", { 15 | searchParams: { 16 | mid: mid, 17 | songid, 18 | quality: vipStatus ? 3 : 2, 19 | privilege: 2, 20 | platform: "web", 21 | }, 22 | headers: { 23 | Cookie: cookie, 24 | Referer: "https://www.bilibili.com/", 25 | Origin: "https://www.bilibili.com", 26 | "User-Agent": UserAgent, 27 | }, 28 | responseType: "json", 29 | }); 30 | 31 | return response.body as AudioStreamUrlResponse; 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/error-fallback/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FallbackProps } from "react-error-boundary"; 2 | import { useNavigate } from "react-router"; 3 | 4 | import { Button } from "@heroui/react"; 5 | 6 | import { ReactComponent as ErrorIllustration } from "@/assets/images/error.svg"; 7 | 8 | const Fallback = ({ resetErrorBoundary }: FallbackProps) => { 9 | const navigate = useNavigate(); 10 | 11 | return ( 12 |
13 | 14 |
15 | 16 | 25 |
26 |
27 | ); 28 | }; 29 | export default Fallback; 30 | -------------------------------------------------------------------------------- /electron/store.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import Store from "electron-store"; 3 | 4 | import { defaultAppSettings } from "@shared/settings/app-settings"; 5 | 6 | import type { FullMediaDownloadTask } from "./ipc/download/types"; 7 | 8 | export const storeKey = { 9 | appSettings: "appSettings", 10 | } as const; 11 | 12 | export const StoreNameMap: Record = { 13 | AppSettings: "app-settings", 14 | UserLoginInfo: "user-login-info", 15 | MediaDownloads: "media-downloads", 16 | } as const; 17 | 18 | export const appSettingsStore = new Store<{ appSettings: AppSettings }>({ 19 | name: StoreNameMap.AppSettings, 20 | defaults: { 21 | appSettings: { 22 | ...defaultAppSettings, 23 | downloadPath: app.getPath("downloads"), 24 | }, 25 | }, 26 | }); 27 | 28 | export const userStore = new Store({ 29 | name: StoreNameMap.UserLoginInfo, 30 | encryptionKey: StoreNameMap.UserLoginInfo, 31 | }); 32 | 33 | export const mediaDownloadsStore = new Store>({ 34 | name: StoreNameMap.MediaDownloads, 35 | }); 36 | -------------------------------------------------------------------------------- /src/common/constants/audio.tsx: -------------------------------------------------------------------------------- 1 | import { RiOrderPlayLine, RiRepeat2Line, RiRepeatOneLine, RiShuffleLine } from "@remixicon/react"; 2 | 3 | /** 4 | * 播放模式 5 | */ 6 | export enum PlayMode { 7 | /** 8 | * 顺序播放 9 | */ 10 | Sequence = 1, 11 | /** 12 | * 循环播放 13 | */ 14 | Loop = 2, 15 | /** 16 | * 随机播放 17 | */ 18 | Random = 3, 19 | /** 20 | * 单曲播放 21 | */ 22 | Single = 4, 23 | } 24 | 25 | export const getPlayModeList = (iconSize?: number) => [ 26 | { 27 | value: PlayMode.Sequence, 28 | desc: "顺序播放", 29 | icon: , 30 | }, 31 | { 32 | value: PlayMode.Loop, 33 | desc: "循环播放", 34 | icon: , 35 | }, 36 | { 37 | value: PlayMode.Random, 38 | desc: "随机播放", 39 | icon: , 40 | }, 41 | { 42 | value: PlayMode.Single, 43 | desc: "单曲播放", 44 | icon: , 45 | }, 46 | ]; 47 | 48 | /** 从低到高音质排,最高为无损 */ 49 | export const audioQualitySort = [30257, 30216, 30259, 30260, 30232, 30280, 30250, 30251]; 50 | -------------------------------------------------------------------------------- /src/layout/playbar/right/mv-fav-folder-select.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { Button, useDisclosure } from "@heroui/react"; 4 | import { RiStarLine } from "@remixicon/react"; 5 | 6 | import FavFolderSelect from "@/components/fav-folder/select"; 7 | import { usePlayList } from "@/store/play-list"; 8 | 9 | const MvFavFolderSelect = () => { 10 | const list = usePlayList(s => s.list); 11 | const playId = usePlayList(s => s.playId); 12 | const playItem = useMemo(() => list.find(item => item.id === playId), [list, playId]); 13 | const { isOpen, onOpen, onOpenChange } = useDisclosure(); 14 | 15 | return ( 16 | <> 17 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | export default MvFavFolderSelect; 31 | -------------------------------------------------------------------------------- /src/store/token.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { create } from "zustand"; 3 | import { persist } from "zustand/middleware"; 4 | 5 | interface TokenState { 6 | /** 刷新 cookie 使用 */ 7 | tokenData?: { 8 | refresh_token?: string; 9 | }; 10 | /** 下次检测刷新时间 */ 11 | nextCheckRefreshTime?: number; 12 | } 13 | 14 | interface Action { 15 | updateToken: (info: Partial) => void; 16 | clear: () => void; 17 | } 18 | 19 | export const useToken = create()( 20 | persist( 21 | set => ({ 22 | tokenData: {}, 23 | nextCheckRefreshTime: moment().unix(), 24 | updateToken: async (info: Partial) => { 25 | set(state => ({ ...state, ...info })); 26 | }, 27 | clear: () => { 28 | set({ 29 | tokenData: undefined, 30 | nextCheckRefreshTime: undefined, 31 | }); 32 | }, 33 | }), 34 | { 35 | name: "user-token", 36 | partialize: state => ({ 37 | tokenData: state.tokenData, 38 | nextCheckRefreshTime: state.nextCheckRefreshTime, 39 | }), 40 | }, 41 | ), 42 | ); 43 | -------------------------------------------------------------------------------- /src/service/fav-folder-deal.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 将视频添加到收藏夹 - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要 csrf)或 APP 7 | */ 8 | export interface FavFolderAddRequestParams { 9 | /** 视频id */ 10 | rid: string; 11 | /** 目标收藏夹id,逗号分隔 */ 12 | add_media_ids?: string; 13 | /** 目标收藏夹id,逗号分隔 */ 14 | del_media_ids?: string; 15 | /** 视频:2 */ 16 | type: number; 17 | /** web */ 18 | platform: string; 19 | /** 1 */ 20 | ga: number; 21 | /** web_normal */ 22 | gaia_source: string; 23 | } 24 | 25 | /** 26 | * 将视频添加到收藏夹 - 响应类型 27 | * data 结构与获取收藏夹元数据的 data 一致 28 | */ 29 | export interface FavFolderAddResponse { 30 | /** 返回值 0:成功 -102:账号被封停 */ 31 | code: number; 32 | /** 错误信息,默认为 "0" */ 33 | message: string; 34 | /** 固定为 1 */ 35 | ttl: number; 36 | } 37 | 38 | /** 39 | * 将视频添加到收藏夹 40 | */ 41 | export function postFavFolderDeal(data: FavFolderAddRequestParams): Promise { 42 | return apiRequest.post("/x/v3/fav/resource/deal", data, { 43 | useCSRF: true, 44 | useFormData: true, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /electron/ipc/window.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain } from "electron"; 2 | 3 | import { channel } from "./channel"; 4 | 5 | export function registerWindowHandlers() { 6 | ipcMain.on(channel.window.minimize, event => { 7 | const win = BrowserWindow.fromWebContents(event.sender); 8 | win?.minimize(); 9 | }); 10 | 11 | ipcMain.on(channel.window.toggleMaximize, event => { 12 | const win = BrowserWindow.fromWebContents(event.sender); 13 | if (win) { 14 | if (win.isMaximized()) { 15 | win.unmaximize(); 16 | } else { 17 | win.maximize(); 18 | } 19 | } 20 | }); 21 | 22 | ipcMain.on(channel.window.close, event => { 23 | const win = BrowserWindow.fromWebContents(event.sender); 24 | win?.close(); 25 | }); 26 | 27 | ipcMain.handle(channel.window.isMaximized, event => { 28 | const win = BrowserWindow.fromWebContents(event.sender); 29 | return win?.isMaximized() ?? false; 30 | }); 31 | 32 | ipcMain.handle(channel.window.isFullScreen, event => { 33 | const win = BrowserWindow.fromWebContents(event.sender); 34 | return win?.isFullScreen() ?? false; 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/service/passport-login-web-cookie-info.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 检查是否需要刷新 Cookie - 请求参数 5 | * 请求方式:GET 6 | * 认证方式:Cookie(需要携带 SESSDATA,csrf 可选) 7 | */ 8 | export interface WebCookieInfoRequestParams { 9 | /** CSRF Token(bili_jct,位于 Cookie),可选 */ 10 | csrf?: string; 11 | } 12 | 13 | /** 14 | * 检查是否需要刷新 Cookie - 顶层响应 15 | */ 16 | export interface WebCookieInfoResponse { 17 | /** 返回值:0 成功;-101 未登录 */ 18 | code: number; 19 | /** 错误信息(默认为 "0") */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体 */ 24 | data: { 25 | /** 是否应该刷新 Cookie:true 需要刷新;false 无需刷新 */ 26 | refresh: boolean; 27 | /** 当前毫秒时间戳(用于生成 CorrespondPath 获取 refresh_csrf) */ 28 | timestamp: number; 29 | }; 30 | } 31 | 32 | /** 33 | * 检查是否需要刷新 Cookie 34 | * @param params 可选参数,仅包含 csrf 35 | * @returns 是否需要刷新及当前时间戳 36 | */ 37 | export function getPassportLoginWebCookieInfo(params?: WebCookieInfoRequestParams): Promise { 38 | return passportRequest.get("/x/passport-login/web/cookie/info", { 39 | params, 40 | skipRefreshCheck: true, 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/service/relation-modify.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | export enum UserRelationAction { 4 | /** 关注 */ 5 | Follow = 1, 6 | /** 取关 */ 7 | Unfollow = 2, 8 | /** 拉黑 */ 9 | Blocked = 5, 10 | /** 取消拉黑 */ 11 | Unblock = 6, 12 | } 13 | 14 | /** 15 | * 操作用户关系 16 | * 请求方式:POST 17 | * 认证方式:Cookie(SESSDATA) 或 APP(access_key) 18 | * act 操作代码: 19 | * 1:关注;2:取关;5:拉黑;6:取消拉黑;7:踢出粉丝;(3:悄悄关注、4:取消悄悄关注已下线) 20 | */ 21 | export interface RelationModifyRequestParams { 22 | /** 目标用户 mid,必要 */ 23 | fid: number; 24 | /** 操作代码,必要(1:关注;2:取关;5:拉黑;6:取消拉黑;7:踢出粉丝) */ 25 | act: number; 26 | /** 关注来源代码,非必要(如:个人空间:11,视频:14,评论区:15 等) */ 27 | re_src?: number; 28 | } 29 | 30 | /** 31 | * 操作用户关系 - 响应类型 32 | */ 33 | export interface RelationModifyResponse { 34 | /** 返回值 0:成功;其他详见文档错误码 */ 35 | code: number; 36 | /** 错误信息,成功为 "0" */ 37 | message: string; 38 | /** 固定为 1 */ 39 | ttl: number; 40 | } 41 | 42 | /** 43 | * 操作用户关系 44 | */ 45 | export function postRelationModify(data: RelationModifyRequestParams) { 46 | return apiRequest.post("/x/relation/modify", data, { useCSRF: true, useFormData: true }); 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/knip-report.yml: -------------------------------------------------------------------------------- 1 | name: Knip Reporter 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | types: [opened, synchronize, reopened] 7 | 8 | permissions: 9 | checks: write 10 | issues: write 11 | pull-requests: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | knip-report: 19 | name: Runner 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Setup pnpm 28 | uses: pnpm/action-setup@v4 29 | 30 | - name: Setup Node.js 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version-file: ".nvmrc" 34 | cache: "pnpm" 35 | 36 | - name: Configure Git to use HTTPS instead of SSH 37 | run: git config --global url."https://github.com/".insteadOf "git@github.com:" 38 | 39 | - name: Install dependencies 40 | run: pnpm install --frozen-lockfile 41 | env: 42 | HUSKY: 0 43 | 44 | - name: Run Knip 45 | run: pnpm knip 46 | -------------------------------------------------------------------------------- /src/service/fav-folder-add.ts: -------------------------------------------------------------------------------- 1 | import type { FavFolderInfoData } from "./fav-folder-info"; 2 | 3 | import { apiRequest } from "./request"; 4 | 5 | /** 6 | * 新建收藏夹 - 请求参数 7 | * 请求方式:POST(application/x-www-form-urlencoded) 8 | * 认证方式:Cookie(需要 csrf)或 APP 9 | */ 10 | export interface FavFolderAddRequestParams { 11 | /** 收藏夹标题 */ 12 | title: string; 13 | /** 收藏夹简介,默认空 */ 14 | intro?: string; 15 | /** 0:公开; 1:私密 */ 16 | privacy?: 0 | 1; 17 | /** 封面图 url(会被审核) */ 18 | cover?: string; 19 | /** CSRF Token(bili_jct),Cookie 方式必要 */ 20 | csrf?: string; 21 | } 22 | 23 | /** 24 | * 新建收藏夹 - 响应类型 25 | * data 结构与获取收藏夹元数据的 data 一致 26 | */ 27 | export interface FavFolderAddResponse { 28 | /** 返回值 0:成功 -102:账号被封停 */ 29 | code: number; 30 | /** 错误信息,默认为 "0" */ 31 | message: string; 32 | /** 固定为 1 */ 33 | ttl: number; 34 | /** 信息本体 */ 35 | data: FavFolderInfoData; 36 | } 37 | 38 | /** 39 | * 新建收藏夹 40 | */ 41 | export function postFavFolderAdd(data: FavFolderAddRequestParams): Promise { 42 | return apiRequest.post("/x/v3/fav/folder/add", data, { 43 | useFormData: true, 44 | useCSRF: true, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/service/fav-folder-edit.ts: -------------------------------------------------------------------------------- 1 | import type { FavFolderInfoData } from "./fav-folder-info"; 2 | 3 | import { apiRequest } from "./request"; 4 | 5 | /** 6 | * 修改收藏夹 - 请求参数 7 | * 请求方式:POST(application/x-www-form-urlencoded) 8 | * 认证方式:Cookie(需要 csrf)或 APP 9 | */ 10 | export interface FavFolderEditRequestParams { 11 | /** 目标收藏夹 mdid(完整 mlid) */ 12 | media_id: number; 13 | /** 修改后的收藏夹标题 */ 14 | title: string; 15 | /** 修改后的收藏夹简介 */ 16 | intro?: string; 17 | /** 0:公开 1:私密 */ 18 | privacy?: 0 | 1; 19 | /** 封面图 url(会被审核) */ 20 | cover?: string; 21 | } 22 | 23 | /** 24 | * 修改收藏夹 - 响应类型 25 | * data 结构与获取收藏夹元数据的 data 一致 26 | */ 27 | export interface FavFolderEditResponse { 28 | /** 返回值 0:成功 -102:账号被封停 */ 29 | code: number; 30 | /** 错误信息,默认为 "0" */ 31 | message: string; 32 | /** 固定为 1 */ 33 | ttl: number; 34 | /** 信息本体 */ 35 | data: FavFolderInfoData; 36 | } 37 | 38 | /** 39 | * 修改收藏夹 40 | */ 41 | export function postFavFolderEdit(data: FavFolderEditRequestParams): Promise { 42 | return apiRequest.post("/x/v3/fav/folder/edit", data, { 43 | useCSRF: true, 44 | useFormData: true, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/service/passport-login-web-confirm-refresh.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 确认更新(使旧 refresh_token 对应的 Cookie 失效)- 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded 或 JSON 直接提交均支持) 6 | * 认证方式:Cookie(需要携带新的 SESSDATA) 7 | */ 8 | export interface WebConfirmRefreshRequestParams { 9 | /** 旧的持久化刷新口令(刷新前 localStorage 中的 ac_time_value 值) */ 10 | refresh_token: string; 11 | } 12 | 13 | /** 14 | * 确认更新 - 顶层响应 15 | */ 16 | export interface WebConfirmRefreshResponse { 17 | /** 返回值:0 成功;-101 未登录;-111 csrf 校验失败;-400 请求错误 */ 18 | code: number; 19 | /** 错误信息(默认为 "0") */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | } 24 | 25 | /** 26 | * 确认更新 27 | * - 该步骤需要使用“刷新后”的新 Cookie(包含新的 SESSDATA 与 bili_jct)。 28 | * - axios 在 withCredentials=true 的情况下会自动携带 Cookie。 29 | */ 30 | export function postPassportLoginWebConfirmRefresh( 31 | data: WebConfirmRefreshRequestParams | URLSearchParams, 32 | ): Promise { 33 | return passportRequest.post("/x/passport-login/web/confirm/refresh", data, { 34 | useCSRF: true, 35 | useFormData: true, 36 | skipRefreshCheck: true, 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/eslint-review.yml: -------------------------------------------------------------------------------- 1 | name: ESLint Review 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | types: [opened, synchronize, reopened] 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | 12 | jobs: 13 | eslint-check: 14 | name: Runner 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version-file: ".nvmrc" 27 | cache: "pnpm" 28 | 29 | - name: Configure Git to use HTTPS instead of SSH 30 | run: git config --global url."https://github.com/".insteadOf "git@github.com:" 31 | 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | env: 35 | HUSKY: 0 36 | 37 | - name: Run ESLint with Reviewdog 38 | uses: reviewdog/action-eslint@v1 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | reporter: github-pr-review 42 | level: error 43 | fail_level: error 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug 反馈 (Bug Report) 2 | description: 提交一个 Bug 报告帮助我们改进 3 | labels: ["🐛bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢您反馈 Bug!请填写以下表单。 9 | 10 | - type: input 11 | id: version 12 | attributes: 13 | label: 应用版本 14 | description: 您当前使用的版本号是多少? 15 | placeholder: v1.0.0 16 | validations: 17 | required: true 18 | 19 | - type: dropdown 20 | id: os 21 | attributes: 22 | label: 操作系统 23 | options: 24 | - Windows 10/11 25 | - macOS 26 | - Linux 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: reproduction 32 | attributes: 33 | label: 问题描述 34 | description: | 35 | 请描述该问题并附带问题截图,方便我们定位问题并排查。 36 | 可选择附上应用日志文件,以便快速定位问题。 37 | 默认日志位置: 38 | - Windows: C:/Users/<用户名>/AppData/Roaming/Biu/logs/main.log 或 renderer.log 39 | - macOS: ~/Library/Application Support/Biu/logs/main.log 或 renderer.log 40 | - Linux: ~/.config/Biu/logs/main.log 或 renderer.log 41 | 您可以直接将日志文件拖拽到下方的输入框中上传。 42 | placeholder: 详细问题描述... 43 | validations: 44 | required: true 45 | -------------------------------------------------------------------------------- /src/service/fav-resource-batch-del.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 批量删除内容 - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要 csrf)或 APP 7 | */ 8 | export interface FavResourceBatchDelRequestParams { 9 | /** 目标内容 id 列表,格式:{内容id}:{内容类型},多个用`,`分隔 10 | * 类型:2:视频稿件 12:音频 21:视频合集 11 | * 内容 id:视频稿件 avid / 音频 auid / 视频合集 id 12 | * 例:"21822819:2,21918689:2,22288065:2" 13 | */ 14 | resources: string; 15 | /** 目标收藏夹 id */ 16 | media_id: number; 17 | /** 平台标识,可为 web */ 18 | platform?: string; 19 | } 20 | 21 | /** 22 | * 批量删除内容 - 响应类型 23 | */ 24 | export interface FavResourceBatchDelResponse { 25 | /** 返回值 0:成功 -101:未登录 -111:csrf校验失败 -400:请求错误 11010:内容不存在 */ 26 | code: number; 27 | /** 错误信息,默认为 "0" */ 28 | message: string; 29 | /** 固定为 1 */ 30 | ttl: number; 31 | /** 信息本体,成功为 0 */ 32 | data: number; 33 | } 34 | 35 | /** 36 | * 批量删除内容 37 | */ 38 | export function postFavResourceBatchDel(data: FavResourceBatchDelRequestParams): Promise { 39 | return apiRequest.post("/x/v3/fav/resource/batch-del", data, { 40 | useCSRF: true, 41 | useFormData: true, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/service/passport-login-web-login-sms.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 使用短信验证码登录_web端请求参数 5 | */ 6 | export interface WebLoginSmsRequestParams { 7 | cid: number; // 国际冠字码 8 | tel: number; // 手机号码 9 | code: number; // 短信验证码 10 | source: string; // 登录来源,main_web:独立登录页 main_mini:小窗登录 11 | captcha_key: string; // 短信登录 token 12 | go_url?: string; // 跳转url,默认为 https://www.bilibili.com 13 | keep?: boolean; // 是否记住登录,true:记住登录 false:不记住登录 14 | } 15 | 16 | /** 17 | * 使用短信验证码登录_web端响应类型 18 | */ 19 | export interface WebLoginSmsResponse { 20 | code: number; // 返回值,0表示成功 21 | message: string; // 错误信息 22 | data: { 23 | hint: string; // 登录提示信息 24 | is_new: boolean; // 是否为新注册用户 25 | status: number; // 状态码 26 | url: string; // 跳转 url 27 | refresh_token: string; // 刷新 token 28 | timestamp: number; // 当前登录的时间戳 29 | }; 30 | } 31 | 32 | /** 33 | * 使用短信验证码登录_web端 34 | * @param params 请求参数 35 | * @returns 登录结果 36 | */ 37 | export function getPassportLoginWebLoginSms(params: WebLoginSmsRequestParams): Promise { 38 | return passportRequest.post("/x/passport-login/web/login/sms", params, { 39 | useFormData: true, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/service/musician-list.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 查询参数:根据文档,level_source 仅给出 1 表示“全部” */ 4 | export interface Params { 5 | /** 分类来源,1:全部; 2:新注册音乐人 */ 6 | level_source: 1 | 2; 7 | } 8 | 9 | /** 音乐人条目结构(与 service.md 示例返回一致) */ 10 | export interface Musician { 11 | id: number; 12 | aid: string; 13 | bvid: string; 14 | archive_count: number; 15 | fans_count: number; 16 | cover: string; 17 | desc: string; 18 | duration: number; 19 | pub_time: number; 20 | danmu_count: number; 21 | self_intro: string; 22 | title: string; 23 | uid: string; 24 | vt_display: string; 25 | vv_count: number; 26 | is_vt: number; 27 | username: string; 28 | user_profile: string; 29 | user_level: number; 30 | lightning: number; 31 | } 32 | 33 | /** 接口返回值结构(与 service.md 示例返回一致) */ 34 | export interface Response { 35 | code: number; 36 | message: string; 37 | ttl: number; 38 | data: { 39 | musicians: Musician[]; 40 | }; 41 | } 42 | 43 | /** 44 | * 查询推荐音乐人 45 | * GET /x/centralization/interface/musician/list 46 | */ 47 | export const getMusicianList = (params: Params) => { 48 | return apiRequest.get("/x/centralization/interface/musician/list", { params }); 49 | }; 50 | -------------------------------------------------------------------------------- /src/service/passport-login-web-login-passport.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 手机号+密码登录_web端请求参数 5 | */ 6 | export interface WebLoginPasswordRequestParams { 7 | username: string; // 用户登录账号 手机号或邮箱地址 8 | password: string; // RSA加密后密码(hash + 密码) 9 | keep: number; // 0 10 | token: string; // 登录 token 11 | challenge: string; // 极验 challenge 12 | validate: string; // 极验 result 13 | seccode: string; // 极验 result +|jordan 14 | source?: string; // 登录来源,main_web:独立登录页 main_mini:小窗登录 15 | go_url?: string; // 跳转 url 16 | } 17 | 18 | /** 19 | * 手机号+密码登录_web端响应 20 | */ 21 | export interface WebLoginPasswordResponse { 22 | code: number; // 0:成功 23 | message: string; 24 | ttl?: number; 25 | data?: { 26 | status?: number; 27 | url?: string; 28 | refresh_token?: string; 29 | timestamp?: number; 30 | message?: string; 31 | }; 32 | } 33 | 34 | /** 35 | * 手机号+密码登录_web端 36 | */ 37 | export function postPassportLoginWebLoginPassword( 38 | params: WebLoginPasswordRequestParams, 39 | ): Promise { 40 | return passportRequest.post("/x/passport-login/web/login", params, { 41 | useFormData: true, // API usually requires form data 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/layout/playbar/right/play-list-drawer/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown, DropdownTrigger, Button, DropdownMenu, DropdownItem, Switch } from "@heroui/react"; 2 | import { RiSettings3Line } from "@remixicon/react"; 3 | 4 | import { usePlayList } from "@/store/play-list"; 5 | 6 | const Settings = () => { 7 | const shouldKeepPagesOrderInRandomPlayMode = usePlayList(s => s.shouldKeepPagesOrderInRandomPlayMode); 8 | const setShouldKeepPagesOrderInRandomPlayMode = usePlayList(s => s.setShouldKeepPagesOrderInRandomPlayMode); 9 | 10 | return ( 11 | 12 | 13 | 16 | 17 | 18 | 19 | 24 | 随机播放时保持分集顺序 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default Settings; 33 | -------------------------------------------------------------------------------- /src/service/relation-stat.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 用户关系状态数(/x/relation/stat) - 请求参数 5 | * 请求方式:GET 6 | * 认证方式:Cookie(SESSDATA)或 APP 7 | */ 8 | export interface RelationStatRequestParams { 9 | /** APP 登录 Token(APP 方式必要) */ 10 | access_key?: string; 11 | /** 目标用户 mid(必要) */ 12 | vmid: number; 13 | } 14 | 15 | /** 16 | * 用户关系状态数(/x/relation/stat) - data 17 | */ 18 | export interface RelationStatData { 19 | /** 目标用户 mid */ 20 | mid: number; 21 | /** 关注数 */ 22 | following: number; 23 | /** 悄悄关注数(未登录或非自己恒为 0) */ 24 | whisper: number; 25 | /** 黑名单数(未登录或非自己恒为 0) */ 26 | black: number; 27 | /** 粉丝数 */ 28 | follower: number; 29 | } 30 | 31 | /** 32 | * 用户关系状态数(/x/relation/stat) - 顶层响应 33 | */ 34 | export interface RelationStatResponse { 35 | /** 返回值 0:成功;-400:请求错误 */ 36 | code: number; 37 | /** 错误信息,默认为 "0" */ 38 | message: string; 39 | /** 固定为 1 */ 40 | ttl: number; 41 | /** 信息本体 */ 42 | data: RelationStatData; 43 | } 44 | 45 | /** 46 | * 查询用户关系状态数 47 | * - 认证:Cookie(SESSDATA)或 APP 48 | */ 49 | export function getRelationStat(params: RelationStatRequestParams): Promise { 50 | return apiRequest.get("/x/relation/stat", { params }); 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/mini-player/use-style.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { useSettings } from "@/store/settings"; 4 | 5 | export const useStyle = () => { 6 | useEffect(() => { 7 | document.documentElement.style.background = "transparent"; 8 | document.body.style.background = "transparent"; 9 | document.body.style.margin = "0"; 10 | document.body.style.overflow = "hidden"; 11 | 12 | const rootEl: HTMLDivElement | null = document.querySelector("#root"); 13 | if (rootEl) { 14 | rootEl.style.background = "rgba(0, 0, 0, 0)"; 15 | rootEl.style.overflow = "hidden"; 16 | rootEl.style.borderRadius = `${useSettings.getState().borderRadius}px`; 17 | } 18 | 19 | return () => { 20 | const rootEl: HTMLDivElement | null = document.querySelector("#root"); 21 | if (rootEl) { 22 | document.documentElement.style.removeProperty("background"); 23 | document.body.style.removeProperty("background"); 24 | document.body.style.removeProperty("margin"); 25 | document.body.style.removeProperty("overflow"); 26 | rootEl.style.removeProperty("background"); 27 | rootEl.style.removeProperty("overflow"); 28 | rootEl.style.removeProperty("border-radius"); 29 | } 30 | }; 31 | }, []); 32 | }; 33 | -------------------------------------------------------------------------------- /src/service/request/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { type CreateAxiosDefaults } from "axios"; 2 | 3 | import { requestInterceptors } from "./request-interceptors"; 4 | 5 | const axiosConfig: CreateAxiosDefaults = { 6 | timeout: 10000, 7 | withCredentials: true, 8 | }; 9 | 10 | export const axiosInstance = axios.create(axiosConfig); 11 | 12 | export const searchRequest = axios.create({ 13 | ...axiosConfig, 14 | baseURL: "https://s.search.bilibili.com", 15 | }); 16 | 17 | export const biliRequest = axios.create({ 18 | ...axiosConfig, 19 | baseURL: "https://www.bilibili.com", 20 | }); 21 | 22 | export const apiRequest = axios.create({ 23 | ...axiosConfig, 24 | baseURL: "https://api.bilibili.com", 25 | }); 26 | 27 | export const passportRequest = axios.create({ 28 | ...axiosConfig, 29 | baseURL: "https://passport.bilibili.com", 30 | }); 31 | 32 | apiRequest.interceptors.request.use(requestInterceptors); 33 | passportRequest.interceptors.request.use(requestInterceptors); 34 | searchRequest.interceptors.request.use(requestInterceptors); 35 | 36 | biliRequest.interceptors.response.use(res => res.data); 37 | apiRequest.interceptors.response.use(res => res.data); 38 | passportRequest.interceptors.response.use(res => res.data); 39 | searchRequest.interceptors.response.use(res => res.data); 40 | -------------------------------------------------------------------------------- /src/components/media-item/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import MusicListItem from "../music-list-item"; 4 | import { type ActionProps } from "../mv-action"; 5 | import MVCard from "../mv-card"; 6 | 7 | interface MediaItemProps extends ActionProps { 8 | displayMode: "card" | "list"; 9 | isTitleIncludeHtmlTag?: boolean; 10 | coverHeight?: number; 11 | footer?: React.ReactNode; 12 | onPress?: () => void; 13 | playCount?: number; 14 | duration?: number; 15 | isActive?: boolean; 16 | } 17 | 18 | const MediaItem: React.FC = ({ 19 | displayMode, 20 | isTitleIncludeHtmlTag, 21 | coverHeight, 22 | footer, 23 | onPress, 24 | playCount, 25 | duration, 26 | ...rest 27 | }) => { 28 | if (displayMode === "list") { 29 | return ( 30 | 37 | ); 38 | } 39 | 40 | return ( 41 | 49 | ); 50 | }; 51 | 52 | export default MediaItem; 53 | -------------------------------------------------------------------------------- /plugins/rsbuild-plugin-electron.ts: -------------------------------------------------------------------------------- 1 | import { logger, type RsbuildPlugin } from "@rsbuild/core"; 2 | import { rimrafSync } from "rimraf"; 3 | 4 | import { buildElectron } from "./electron-build"; 5 | import { buildElectronConfig } from "./electron-config-build"; 6 | import { startElectronDev } from "./electron-dev"; 7 | 8 | export const pluginElectron = (): RsbuildPlugin => ({ 9 | name: "plugin-electron", 10 | setup(api) { 11 | api.onAfterDevCompile(async ({ isFirstCompile }) => { 12 | if (isFirstCompile) { 13 | logger.info("[electron] Bundle the typescript configuration for electron..."); 14 | await buildElectronConfig("development"); 15 | 16 | startElectronDev(); 17 | } 18 | }); 19 | 20 | if (process.env.BUILD_WEB !== "true") { 21 | api.onBeforeBuild(async () => { 22 | logger.info("Cleaning dist directory..."); 23 | try { 24 | rimrafSync("dist"); 25 | } catch (err) { 26 | logger.error(`Clean dist failed: ${String((err && (err as any).message) || err)}`); 27 | } 28 | 29 | logger.info("[electron] Bundling Electron TypeScript..."); 30 | await buildElectronConfig(); 31 | }); 32 | 33 | api.onAfterBuild(async () => { 34 | await buildElectron(); 35 | }); 36 | } 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /shared/types/user.d.ts: -------------------------------------------------------------------------------- 1 | interface UserInfo { 2 | isLogin: boolean; // 是否已登录 false:未登录 true:已登录 3 | email_verified: number; // 是否验证邮箱地址 0:未验证 1:已验证 4 | face: string; // 用户头像 url 5 | face_nft?: number; // 是否为 NFT 头像 0:不是 1:是 6 | face_nft_type?: number; // NFT 头像类型 7 | level_info: LevelInfo; // 等级信息 8 | mid: number; // 用户 mid 9 | mobile_verified: number; // 是否验证手机号 0:未验证 1:已验证 10 | money: number; // 拥有硬币数 11 | moral: number; // 当前节操值 上限为70 12 | official: Official; // 认证信息 13 | officialVerify: OfficialVerify; // 认证信息 2 14 | pendant: Pendant; // 头像框信息 15 | scores: number; // (?) 16 | uname: string; // 用户昵称 17 | vipDueDate: number; // 会员到期时间 毫秒时间戳 18 | vipStatus: number; // 会员开通状态 0:无 1:有 19 | vipType: number; // 会员类型 0:无 1:月度大会员 2:年度及以上大会员 20 | vip_pay_type: number; // 会员开通状态 0:无 1:有 21 | vip_theme_type: number; // (?) 22 | vip_label: VipLabel; // 会员标签 23 | vip_avatar_subscript: number; // 是否显示会员图标 0:不显示 1:显示 24 | vip_nickname_color: string; // 会员昵称颜色 颜色码 25 | wallet: Wallet; // B币钱包信息 26 | has_shop: boolean; // 是否拥有推广商品 false:无 true:有 27 | shop_url: string; // 商品推广页面 url 28 | allowance_count: number; // (?) 29 | answer_status: number; // (?) 30 | is_senior_member: number; // 是否硬核会员 0:非硬核会员 1:硬核会员 31 | wbi_img: WbiImg; // Wbi 签名实时口令 该字段即使用户未登录也存在 32 | is_jury: boolean; // 是否风纪委员 true:风纪委员 false:非风纪委员 33 | } 34 | -------------------------------------------------------------------------------- /src/service/passport-login-exit.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 退出账号登录(Web)- 请求参数 5 | * 请求方式:POST(建议使用 application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要携带 DedeUserID、bili_jct、SESSDATA) 7 | */ 8 | export interface PassportLoginExitRequestParams { 9 | /** CSRF Token(位于 cookie 中的 bili_jct,字段名需为 biliCSRF) */ 10 | biliCSRF: string; 11 | /** 成功后跳转到的页面,可选,默认 javascript:history.go(-1) */ 12 | gourl?: string; 13 | } 14 | 15 | /** 16 | * 退出账号登录(Web)- 顶层响应 17 | * 注意:当 Cookie 已失效时,服务端可能直接返回登录页 HTML,而非 JSON。 18 | */ 19 | export interface PassportLoginExitResponse { 20 | /** 返回值:0 成功;2202 csrf 请求非法 */ 21 | code: number; 22 | /** 成功时通常存在且为 true */ 23 | status?: boolean; 24 | /** 时间戳(秒) */ 25 | ts?: number; 26 | /** 错误信息(成功时可能不存在) */ 27 | message?: string; 28 | /** 成功时返回的数据本体,包含重定向 URL */ 29 | data?: { 30 | redirectUrl: string; 31 | }; 32 | } 33 | 34 | /** 35 | * 退出账号登录(Web) 36 | * - 建议以 x-www-form-urlencoded 方式提交:可传入 URLSearchParams 37 | * - 也可直接传对象(将以 JSON 方式提交,可能不被服务端接受,按需选择) 38 | * - axios 在 withCredentials=true 的情况下会自动携带 Cookie 39 | */ 40 | export function postPassportLoginExit(data: PassportLoginExitRequestParams): Promise { 41 | return passportRequest.post("/login/exit/v2", data, { 42 | useFormData: true, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/store/search-history.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | export interface SearchItem { 5 | value: string; 6 | time: number; 7 | } 8 | 9 | export interface SearchHistoryState { 10 | keyword: string; 11 | items: SearchItem[]; 12 | } 13 | 14 | export interface SearchHistoryAction { 15 | add: (value: string) => void; 16 | delete: (item: SearchItem) => void; 17 | clear: () => void; 18 | } 19 | 20 | export const useSearchHistory = create()( 21 | persist( 22 | (set, get) => ({ 23 | keyword: "", 24 | items: [], 25 | add: value => { 26 | const { items } = get(); 27 | const newItem = { value, time: Date.now() }; 28 | 29 | if (items.some(i => i.value === value)) { 30 | set({ keyword: value, items: [newItem, ...items.filter(i => i.value !== value)] }); 31 | } else { 32 | set({ keyword: value, items: [...items, newItem] }); 33 | } 34 | }, 35 | delete: item => set(state => ({ items: state.items.filter(i => i.value !== item.value) })), 36 | clear: () => set({ keyword: "", items: [] }), 37 | }), 38 | { 39 | name: "search-history", 40 | partialize: state => ({ keyword: state.keyword, items: state.items }), 41 | }, 42 | ), 43 | ); 44 | -------------------------------------------------------------------------------- /src/service/fav-video-favoured.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 判断视频是否被收藏 (双端) 5 | * - GET https://api.bilibili.com/x/v2/fav/video/favoured 6 | * - 认证方式:APP(access_key)或 Cookie(SESSDATA) 7 | */ 8 | export interface FavVideoFavouredParams { 9 | /** APP 登录 Token(APP 方式必要) */ 10 | access_key?: string; 11 | /** 稿件 avid 或稿件 bvid(必要) */ 12 | aid: number | string; 13 | } 14 | 15 | /** 顶层响应 */ 16 | export interface FavVideoFavouredResponse { 17 | /** 返回值:0 成功;-400 请求错误;-101 账号未登录 */ 18 | code: number; 19 | /** 错误信息,默认为 "0" */ 20 | message: string; 21 | /** 固定为 1 */ 22 | ttl: number; 23 | /** 信息本体 */ 24 | data: FavVideoFavouredData; 25 | } 26 | 27 | /** data 对象 */ 28 | export interface FavVideoFavouredData { 29 | /** 作用尚不明确,示例为 1 */ 30 | count: number; 31 | /** 是否收藏:true 已收藏;false 未收藏 */ 32 | favoured: boolean; 33 | } 34 | 35 | /** 36 | * 判断视频是否被收藏 37 | * - 认证:APP 或 Cookie(SESSDATA) 38 | * - 参数:`aid` 可同时接受 avid(数字)或 bvid(字符串) 39 | * 40 | * 示例: 41 | * ```ts 42 | * await getFavVideoFavoured({ aid: 46281123 }); // avid 43 | * await getFavVideoFavoured({ aid: "BV1Bb411H7Dv" }); // bvid 44 | * ``` 45 | */ 46 | export function getFavVideoFavoured(params: FavVideoFavouredParams): Promise { 47 | return apiRequest.get("/x/v2/fav/video/favoured", { params }); 48 | } 49 | -------------------------------------------------------------------------------- /src/service/gaia-vgate-validate.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 从验证结果获取 grisk_id(gaia_vtoken)- 请求参数 5 | * POST /x/gaia-vgate/v1/validate 6 | * 正文:application/x-www-form-urlencoded 7 | */ 8 | export interface GaiaVgateValidateRequestParams { 9 | /** CSRF Token(位于 Cookie 的 bili_jct),非必要;若已登录则必要 */ 10 | csrf?: string; 11 | /** 验证码 challenge(必要) */ 12 | challenge: string; 13 | /** 验证码 token(必要) */ 14 | token: string; 15 | /** 验证结果 validate(必要) */ 16 | validate: string; 17 | /** 验证结果 seccode(必要) */ 18 | seccode: string; 19 | } 20 | 21 | /** validate 接口 data */ 22 | export interface GaiaVgateValidateData { 23 | /** 验证结果:1 验证成功 */ 24 | is_valid: number; 25 | /** gaia_vtoken(用于恢复正常访问) */ 26 | grisk_id: string; 27 | } 28 | 29 | /** 顶层响应 */ 30 | export interface GaiaVgateValidateResponse { 31 | /** 返回值:0 成功;-111 csrf 校验失败;100003 验证码过期 */ 32 | code: number; 33 | /** 错误信息,默认为 "0" */ 34 | message: string; 35 | /** 固定为 1 */ 36 | ttl: number; 37 | /** 信息本体 */ 38 | data: GaiaVgateValidateData; 39 | } 40 | 41 | /** 42 | * 从验证结果获取 grisk_id(gaia_vtoken) 43 | */ 44 | export function postGaiaVgateValidate(data: GaiaVgateValidateRequestParams): Promise { 45 | return apiRequest.post("/x/gaia-vgate/v1/validate", data, { 46 | useFormData: true, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/service/space-setting.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | export interface GetSpaceSettingsParams { 4 | mid: string | number; 5 | /** 333.1387 */ 6 | web_location: string; 7 | } 8 | 9 | export interface GetSpaceSettingsResponse { 10 | code: number; 11 | message: string; 12 | ttl: number; 13 | data: Data; 14 | } 15 | 16 | export interface Data { 17 | privacy: Privacy; 18 | show_nft_switch: boolean; 19 | index_order: IndexOrder[]; 20 | } 21 | 22 | /** 23 | * 0:隐藏 1:公开 24 | */ 25 | export interface Privacy { 26 | bangumi: number; 27 | bbq: number; 28 | channel: number; 29 | charge_video: number; 30 | close_space_medal: number; 31 | coins_video: number; 32 | comic: number; 33 | disable_following: number; 34 | disable_show_fans: number; 35 | disable_show_school: number; 36 | dress_up: number; 37 | /** 收藏夹 */ 38 | fav_video: number; 39 | groups: number; 40 | lesson_video: number; 41 | likes_video: number; 42 | live_playback: number; 43 | only_show_wearing: number; 44 | played_game: number; 45 | tags: number; 46 | user_info: number; 47 | } 48 | 49 | export interface IndexOrder { 50 | id: number; 51 | name: string; 52 | } 53 | 54 | export const getXSpaceSettings = (params: GetSpaceSettingsParams) => { 55 | return apiRequest.get("/x/space/setting", { params }); 56 | }; 57 | -------------------------------------------------------------------------------- /rsbuild.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@rsbuild/core"; 2 | import { pluginReact } from "@rsbuild/plugin-react"; 3 | import { pluginSvgr } from "@rsbuild/plugin-svgr"; 4 | 5 | import { pluginElectron } from "./plugins/rsbuild-plugin-electron"; 6 | 7 | export default defineConfig({ 8 | output: { 9 | distPath: { 10 | root: "./dist/web", 11 | }, 12 | // 生产环境相对路径,保证通过 file:// 加载时静态资源能正确引用 13 | assetPrefix: "./", 14 | cleanDistPath: true, 15 | }, 16 | performance: { 17 | removeMomentLocale: true, 18 | }, 19 | html: { 20 | template: "./src/index.html", 21 | }, 22 | plugins: [ 23 | pluginReact(), 24 | pluginSvgr({ 25 | svgrOptions: { 26 | exportType: "named", 27 | // Enable SVGO to optimize inline SVGs 28 | svgo: true, 29 | svgoConfig: { 30 | plugins: [ 31 | { 32 | name: "preset-default", 33 | params: { overrides: { removeViewBox: false } }, 34 | }, 35 | ], 36 | }, 37 | }, 38 | }), 39 | pluginElectron(), 40 | ], 41 | dev: { 42 | writeToDisk: true, 43 | lazyCompilation: false, 44 | cliShortcuts: false, 45 | // 开发环境相对路径,保证通过 file:// 加载时静态资源能正确引用 46 | assetPrefix: "./", 47 | }, 48 | server: { 49 | printUrls: false, 50 | open: false, 51 | compress: false, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /shared/types/download.d.ts: -------------------------------------------------------------------------------- 1 | type MediaDownloadStatus = "waiting" | "downloading" | "merging" | "converting" | "paused" | "completed" | "failed"; 2 | 3 | type MediaDownloadOutputFileType = "audio" | "video"; 4 | 5 | interface MediaDownloadInfo { 6 | /** 文件类型 */ 7 | outputFileType: MediaDownloadOutputFileType; 8 | /** 标题 */ 9 | title: string; 10 | /** 封面 */ 11 | cover?: string; 12 | /** 视频bvid */ 13 | bvid?: string; 14 | /** 视频分集cid */ 15 | cid?: string | number; 16 | /** 音频sid */ 17 | sid?: string | number; 18 | } 19 | 20 | interface MediaDownloadTaskBase extends MediaDownloadInfo { 21 | id: string; 22 | status: MediaDownloadStatus; 23 | createdTime: number; 24 | } 25 | 26 | interface MediaDownloadTask extends MediaDownloadTaskBase { 27 | /** 音频编码格式 */ 28 | audioCodecs?: string; 29 | /** 音频带宽 */ 30 | audioBandwidth?: number; 31 | /** 视频分辨率 */ 32 | videoResolution?: string; 33 | /** 视频帧率 */ 34 | videoFrameRate?: string; 35 | /** 保存路径 */ 36 | savePath?: string; 37 | /** 文件大小 bytes */ 38 | totalBytes?: number; 39 | /** 下载进度百分比 */ 40 | downloadProgress?: number; 41 | /** 合并进度百分比 */ 42 | mergeProgress?: number; 43 | /** ffmpeg 转换进度百分比 */ 44 | convertProgress?: number; 45 | /** 下载错误信息 */ 46 | error?: string; 47 | } 48 | 49 | interface MediaDownloadBroadcastPayload { 50 | type: "full" | "update"; 51 | data: MediaDownloadTask[]; 52 | } 53 | -------------------------------------------------------------------------------- /src/service/player-pagelist.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 查询视频分P列表 - 请求参数 5 | */ 6 | export interface PlayerPagelistRequestParams { 7 | aid?: number; // 稿件avid 8 | bvid?: string; // 稿件bvid 9 | } 10 | 11 | /** 12 | * 查询视频分P列表 - 响应类型 13 | */ 14 | export interface PlayerPagelistResponse { 15 | code: number; // 返回值 0:成功 -400:请求错误 -404:无视频 16 | message: string; // 错误信息 17 | ttl: number; // 1 18 | data: PlayerPagelistItem[]; 19 | } 20 | 21 | /** 22 | * 查询视频分P列表 - 分P条目 23 | */ 24 | export interface PlayerPagelistItem { 25 | cid: number; // 当前分P cid 26 | page: number; // 当前分P 27 | from: string; // 视频来源 vupload:普通上传(B站) hunan:芒果TV qq:腾讯 28 | part: string; // 当前分P标题 29 | duration: number; // 当前分P持续时间 单位为秒 30 | vid: string; // 站外视频vid 31 | weblink: string; // 站外视频跳转url 32 | dimension: PlayerPagelistDimension; // 当前分P分辨率 33 | first_frame: string; // 分P封面 34 | } 35 | 36 | /** 37 | * 查询视频分P列表 - 分辨率信息 38 | */ 39 | export interface PlayerPagelistDimension { 40 | width: number; // 当前分P 宽度 41 | height: number; // 当前分P 高度 42 | rotate: number; // 是否将宽高对换 0:正常 1:对换 43 | } 44 | 45 | /** 46 | * 查询视频分P列表 47 | * @param params 请求参数 48 | * @returns Promise 49 | */ 50 | export const getPlayerPagelist = (params: PlayerPagelistRequestParams) => { 51 | return apiRequest.get("/x/player/pagelist", { params }); 52 | }; 53 | -------------------------------------------------------------------------------- /src/service/fav-resource-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 收藏夹资源工具函数 3 | */ 4 | import { type FavMedia, type FavResourceListRequestParams } from "./fav-resource"; 5 | 6 | /** 7 | * 过滤和排序收藏夹内容 8 | * @param medias 收藏夹内容列表 9 | * @param params 过滤和排序参数 10 | * @returns 过滤和排序后的收藏夹内容列表 11 | */ 12 | export const filterAndSortFavMedias = ( 13 | medias: FavMedia[], 14 | params: Pick, 15 | ): FavMedia[] => { 16 | let filteredMedias = [...medias]; 17 | 18 | // 1. 按关键字过滤 19 | if (params && params.keyword) { 20 | const keyword = params.keyword.toLowerCase(); 21 | filteredMedias = filteredMedias.filter( 22 | media => 23 | media.title.toLowerCase().includes(keyword) || (media.intro && media.intro.toLowerCase().includes(keyword)), 24 | ); 25 | } 26 | 27 | // 2. 按指定字段排序 28 | if (params.order) { 29 | filteredMedias.sort((a, b) => { 30 | switch (params.order) { 31 | case "mtime": 32 | // 按收藏时间排序,最新的在前 33 | return b.fav_time - a.fav_time; 34 | case "view": 35 | // 按播放量排序,播放量高的在前 36 | return (b.cnt_info.play || 0) - (a.cnt_info.play || 0); 37 | case "pubtime": 38 | // 按投稿时间排序,最新的在前 39 | return b.pubtime - a.pubtime; 40 | default: 41 | // 默认按收藏时间排序 42 | return b.fav_time - a.fav_time; 43 | } 44 | }); 45 | } 46 | return filteredMedias; 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/update-check-button/index.tsx: -------------------------------------------------------------------------------- 1 | import { addToast, useDisclosure } from "@heroui/react"; 2 | 3 | import { useAppUpdateStore } from "@/store/app-update"; 4 | 5 | import AsyncButton from "../async-button"; 6 | import ReleaseNoteModal from "../release-note-modal"; 7 | 8 | const UpdateCheckButton = () => { 9 | const isUpdateAvailable = useAppUpdateStore(s => s.isUpdateAvailable); 10 | 11 | const { 12 | isOpen: isReleaseNoteModalOpen, 13 | onOpen: onReleaseNoteModalOpen, 14 | onOpenChange: onReleaseNoteModalOpenChange, 15 | } = useDisclosure(); 16 | 17 | const checkUpdate = async () => { 18 | if (isUpdateAvailable) { 19 | onReleaseNoteModalOpen(); 20 | 21 | return; 22 | } 23 | 24 | const res = await window.electron.checkAppUpdate(); 25 | 26 | if (res?.error) { 27 | addToast({ 28 | title: "检查更新失败", 29 | description: res.error, 30 | color: "danger", 31 | }); 32 | } else if (res?.isUpdateAvailable) { 33 | onReleaseNoteModalOpen(); 34 | } else { 35 | addToast({ 36 | title: "当前版本为最新版本", 37 | }); 38 | } 39 | }; 40 | 41 | return ( 42 | <> 43 | {isUpdateAvailable ? "查看更新内容" : "检查更新"} 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default UpdateCheckButton; 50 | -------------------------------------------------------------------------------- /src/pages/search/user-list.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useNavigate } from "react-router"; 3 | 4 | import { Card, CardBody, Avatar } from "@heroui/react"; 5 | 6 | import type { SearchUserItem } from "@/service/web-interface-search-type"; 7 | 8 | import { formatUrlProtocal } from "@/common/utils/url"; 9 | import Empty from "@/components/empty"; 10 | 11 | export type SearchUserProps = { 12 | items: SearchUserItem[]; 13 | }; 14 | 15 | export default function SearchUser({ items }: SearchUserProps) { 16 | const navigate = useNavigate(); 17 | 18 | if (!items || items.length === 0) { 19 | return ; 20 | } 21 | 22 | return ( 23 |
24 | {items.map(u => ( 25 | navigate(`/user/${u.mid}`)}> 26 | 27 | 28 |
29 | {u.uname} 30 | {u.usign} 31 |
32 |
33 |
34 | ))} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/service/music-comprehensive-web-rank.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | // 请求参数(与 service.md 保持一致) 4 | export interface Params { 5 | /** 页码,默认 1 */ 6 | pn?: number; 7 | /** 每页数量,默认 20 */ 8 | ps?: number; 9 | /** 默认 333.1351 */ 10 | web_location?: string; 11 | } 12 | 13 | // 关联稿件信息 14 | export interface RelatedArchive { 15 | aid: string; 16 | bvid: string; 17 | cid: string; 18 | cover: string; 19 | title: string; 20 | uid: number; 21 | username: string; 22 | vt_display: string; 23 | vv_count: number; 24 | is_vt: number; 25 | fname: string; 26 | duration: number; 27 | } 28 | 29 | // 列表项数据结构 30 | export interface Data { 31 | music_title: string; 32 | music_id: string; 33 | music_corner: string; 34 | cid: string; 35 | jump_url: string; 36 | author: string; 37 | bvid: string; 38 | album: string; 39 | aid: string; 40 | id: number; 41 | cover: string; 42 | score: number; 43 | related_archive: RelatedArchive; 44 | } 45 | 46 | // 接口返回值结构 47 | export interface Response { 48 | code: number; 49 | message: string; 50 | ttl: number; 51 | data: { 52 | list: Data[]; 53 | }; 54 | } 55 | 56 | /** 57 | * 更多音乐推荐 58 | * GET /x/centralization/interface/music/comprehensive/web/rank 59 | */ 60 | export const getMusicComprehensiveWebRank = (params: Params) => { 61 | return apiRequest.get("/x/centralization/interface/music/comprehensive/web/rank", { params }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/service/gaia-vgate-register.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 从 v_voucher 申请 captcha - 请求参数 5 | * POST /x/gaia-vgate/v1/register 6 | * 正文:application/x-www-form-urlencoded 7 | */ 8 | export interface GaiaVgateRegisterRequestParams { 9 | /** v_voucher 字符串(必要) */ 10 | v_voucher: string; 11 | } 12 | 13 | /** 极验信息 */ 14 | export interface GaiaGeetestInfo { 15 | /** 极验 id,一般为固定值 */ 16 | gt: string; 17 | /** 极验 KEY,由后端产生 */ 18 | challenge: string; 19 | } 20 | 21 | /** register 接口 data */ 22 | export interface GaiaVgateRegisterData { 23 | /** 验证码类型,目前只有 "geetest" */ 24 | type: string; 25 | /** 验证码 token(用于后续 validate) */ 26 | token: string; 27 | /** 极验信息;为 null 则说明该风控无法通过 captcha 解除 */ 28 | geetest: GaiaGeetestInfo | null; 29 | /** 以下字段文档为 null,占位保持一致 */ 30 | biliword: null; 31 | phone: null; 32 | sms: null; 33 | } 34 | 35 | /** 顶层响应 */ 36 | export interface GaiaVgateRegisterResponse { 37 | /** 返回值:0 成功;100000 验证码获取失败 */ 38 | code: number; 39 | /** 错误信息,默认为 "0" */ 40 | message: string; 41 | /** 固定为 1 */ 42 | ttl: number; 43 | /** 信息本体 */ 44 | data: GaiaVgateRegisterData; 45 | } 46 | 47 | /** 48 | * 从 v_voucher 申请 captcha 49 | */ 50 | export function postGaiaVgateRegister(data: GaiaVgateRegisterRequestParams): Promise { 51 | return apiRequest.post("/x/gaia-vgate/v1/register", data, { 52 | useFormData: true, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/later/action.tsx: -------------------------------------------------------------------------------- 1 | import { Button, useDisclosure } from "@heroui/react"; 2 | import { RiDeleteBinLine } from "@remixicon/react"; 3 | 4 | import ConfirmModal from "@/components/confirm-modal"; 5 | import { postHistoryToViewDel } from "@/service/history-toview-del"; 6 | import { type ToViewVideoItem } from "@/service/history-toview-list"; 7 | 8 | interface Props { 9 | data?: ToViewVideoItem; 10 | refresh: () => void; 11 | } 12 | 13 | const ActionMenu = ({ data, refresh }: Props) => { 14 | const { isOpen, onOpen, onOpenChange } = useDisclosure(); 15 | 16 | return ( 17 |
18 | 28 | { 35 | const res = await postHistoryToViewDel({ 36 | aid: data?.aid, 37 | }); 38 | 39 | if (res.code === 0) { 40 | refresh?.(); 41 | } 42 | 43 | return res.code === 0; 44 | }} 45 | /> 46 |
47 | ); 48 | }; 49 | 50 | export default ActionMenu; 51 | -------------------------------------------------------------------------------- /src/layout/playbar/right/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, useDisclosure } from "@heroui/react"; 2 | import { RiPlayListLine } from "@remixicon/react"; 3 | 4 | import { usePlayList } from "@/store/play-list"; 5 | import { useUser } from "@/store/user"; 6 | 7 | import { PlayBarIconSize } from "../constants"; 8 | import Download from "./download"; 9 | import MvFavFolderSelect from "./mv-fav-folder-select"; 10 | import PlayListDrawer from "./play-list-drawer"; 11 | import PlayModeSwitch from "./play-mode"; 12 | import Rate from "./rate"; 13 | import Volume from "./volume"; 14 | 15 | const RightControl = () => { 16 | const user = useUser(s => s.user); 17 | const playId = usePlayList(s => s.playId); 18 | const { isOpen: isQueueOpen, onOpen: onQueueOpen, onOpenChange: onQueueOpenChange } = useDisclosure(); 19 | 20 | return ( 21 | <> 22 |
23 | 24 | {Boolean(user?.isLogin) && Boolean(playId) && } 25 | {Boolean(playId) && } 26 | 29 | 30 | 31 |
32 | 33 | 34 | ); 35 | }; 36 | 37 | export default RightControl; 38 | -------------------------------------------------------------------------------- /src/components/font-select/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import { Select, SelectItem } from "@heroui/react"; 4 | 5 | import { defaultAppSettings } from "@shared/settings/app-settings"; 6 | 7 | export interface FontSelectProps { 8 | color?: "primary" | "secondary"; 9 | value?: string; 10 | onChange: (value: string) => void; 11 | className?: string; 12 | } 13 | 14 | export default function FontSelect({ 15 | color, 16 | value = defaultAppSettings.fontFamily, 17 | onChange, 18 | className, 19 | }: FontSelectProps) { 20 | const [fonts, setFonts] = useState[]>([]); 21 | 22 | const getFonts = async () => { 23 | const fonts = await window.electron.getFonts(); 24 | setFonts([{ name: "系统默认", familyName: defaultAppSettings.fontFamily }, ...fonts]); 25 | }; 26 | 27 | useEffect(() => { 28 | getFonts(); 29 | }, []); 30 | 31 | const selectedValue = value === "system-default" ? "system-ui" : value; 32 | 33 | return ( 34 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/common/constants/menus.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RiMvLine, 3 | RiMvFill, 4 | RiGroupLine, 5 | RiGroupFill, 6 | RiDiscLine, 7 | RiDiscFill, 8 | RiUserFollowLine, 9 | RiUserFollowFill, 10 | RiFileDownloadLine, 11 | RiFileDownloadFill, 12 | RiTimeLine, 13 | RiTimeFill, 14 | RiHistoryLine, 15 | RiHistoryFill, 16 | } from "@remixicon/react"; 17 | 18 | import { type MenuItemProps } from "@/components/menu/menu-item"; 19 | 20 | export const DefaultMenuList: (MenuItemProps & { needLogin?: boolean })[] = [ 21 | { 22 | title: "热歌精选", 23 | href: "/", 24 | icon: RiMvLine, 25 | activeIcon: RiMvFill, 26 | }, 27 | { 28 | title: "音乐大咖", 29 | href: "/artist-rank", 30 | icon: RiGroupLine, 31 | activeIcon: RiGroupFill, 32 | }, 33 | { 34 | title: "推荐音乐", 35 | href: "/music-recommend", 36 | icon: RiDiscLine, 37 | activeIcon: RiDiscFill, 38 | }, 39 | { 40 | title: "我的关注", 41 | href: "/follow", 42 | needLogin: true, 43 | icon: RiUserFollowLine, 44 | activeIcon: RiUserFollowFill, 45 | }, 46 | { 47 | title: "稍后再看", 48 | href: "/later", 49 | needLogin: true, 50 | icon: RiTimeLine, 51 | activeIcon: RiTimeFill, 52 | }, 53 | { 54 | title: "历史记录", 55 | href: "/history", 56 | needLogin: true, 57 | icon: RiHistoryLine, 58 | activeIcon: RiHistoryFill, 59 | }, 60 | { 61 | title: "下载记录", 62 | href: "/download-list", 63 | icon: RiFileDownloadLine, 64 | activeIcon: RiFileDownloadFill, 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /src/layout/navbar/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Divider, Modal, ModalBody, ModalContent, Tab, Tabs } from "@heroui/react"; 4 | 5 | import CodeLogin from "./code-login"; 6 | import PasswordLogin from "./password-login"; 7 | import QrcodeLogin from "./qrcode-login"; 8 | 9 | interface Props { 10 | isOpen: boolean; 11 | onOpenChange: (isOpen: boolean) => void; 12 | } 13 | 14 | const Login = ({ isOpen, onOpenChange }: Props) => { 15 | const onClose = () => onOpenChange(false); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 |
24 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default Login; 46 | -------------------------------------------------------------------------------- /electron/ipc/download.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "electron"; 2 | 3 | import type { IpcHandlerProps } from "./types"; 4 | 5 | import { channel } from "./channel"; 6 | import { DownloadQueue } from "./download/download-queue"; 7 | 8 | let downloadQueue: DownloadQueue; 9 | 10 | export function registerDownloadHandlers({ getMainWindow }: IpcHandlerProps) { 11 | downloadQueue = new DownloadQueue(getMainWindow); 12 | 13 | ipcMain.handle(channel.download.getList, async () => { 14 | return downloadQueue.getBroadcastTaskDataList(); 15 | }); 16 | 17 | ipcMain.handle(channel.download.add, async (_, task: MediaDownloadTask) => { 18 | return downloadQueue.addTask(task); 19 | }); 20 | 21 | ipcMain.handle(channel.download.addList, async (_, tasks: MediaDownloadTask[]) => { 22 | return downloadQueue.addTasks(tasks); 23 | }); 24 | 25 | ipcMain.handle(channel.download.pause, async (_, id: string) => { 26 | downloadQueue.pauseTask(id); 27 | }); 28 | 29 | ipcMain.handle(channel.download.resume, async (_, id: string) => { 30 | downloadQueue.resumeTask(id); 31 | }); 32 | 33 | ipcMain.handle(channel.download.cancel, async (_, id: string) => { 34 | downloadQueue.cancelTask(id); 35 | }); 36 | 37 | ipcMain.handle(channel.download.retry, async (_, id: string) => { 38 | downloadQueue.retryTask(id); 39 | }); 40 | 41 | ipcMain.handle(channel.download.clear, async () => { 42 | downloadQueue.clearTasks(); 43 | }); 44 | } 45 | 46 | export function saveDownloadQueue() { 47 | downloadQueue.saveAllTasksToStore(); 48 | } 49 | -------------------------------------------------------------------------------- /src/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { ErrorBoundary } from "react-error-boundary"; 3 | import { Outlet, useLocation } from "react-router"; 4 | 5 | import log from "electron-log/renderer"; 6 | 7 | import Fallback from "@/components/error-fallback"; 8 | import PlayBar from "@/layout/playbar"; 9 | import { useUser } from "@/store/user"; 10 | 11 | import Navbar from "./navbar"; 12 | import SideNav from "./side"; 13 | 14 | const Layout = () => { 15 | const updateUser = useUser(state => state.updateUser); 16 | const location = useLocation(); 17 | 18 | useEffect(() => { 19 | updateUser(); 20 | }, []); 21 | 22 | return ( 23 | { 27 | log.error("[ErrorBoundary]", error, info); 28 | }} 29 | > 30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 | 44 |
45 |
46 |
47 | ); 48 | }; 49 | 50 | export default Layout; 51 | -------------------------------------------------------------------------------- /electron/ipc/app.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, shell } from "electron"; 2 | 3 | import { autoUpdater, getDownloadedFilePath } from "../updater"; 4 | import { channel } from "./channel"; 5 | 6 | export function registerAppHandlers() { 7 | ipcMain.handle(channel.app.getVersion, async () => { 8 | return app.getVersion(); 9 | }); 10 | 11 | ipcMain.handle(channel.app.checkUpdate, async (): Promise => { 12 | try { 13 | const res = await autoUpdater.checkForUpdates(); 14 | 15 | if (res?.isUpdateAvailable) { 16 | return { 17 | isUpdateAvailable: res?.isUpdateAvailable, 18 | latestVersion: res?.updateInfo?.version as string, 19 | releaseNotes: res?.updateInfo?.releaseNotes as string, 20 | }; 21 | } 22 | 23 | return { 24 | isUpdateAvailable: false, 25 | }; 26 | } catch (error) { 27 | return { 28 | isUpdateAvailable: false, 29 | error: String(error instanceof Error ? error?.message : "无法获取更新信息"), 30 | }; 31 | } 32 | }); 33 | 34 | ipcMain.handle(channel.app.downloadUpdate, async () => { 35 | await autoUpdater.downloadUpdate(); 36 | }); 37 | 38 | ipcMain.handle(channel.app.quitAndInstall, async () => { 39 | return autoUpdater.quitAndInstall(); 40 | }); 41 | 42 | ipcMain.handle(channel.app.openInstallerDirectory, async () => { 43 | const path = getDownloadedFilePath(); 44 | if (path) { 45 | shell.showItemInFolder(path); 46 | return true; 47 | } 48 | return false; 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/color-picker/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { HexColorPicker } from "react-colorful"; 3 | 4 | import { Button, Popover, PopoverContent, PopoverTrigger } from "@heroui/react"; 5 | import { twMerge } from "tailwind-merge"; 6 | 7 | export interface ColorPickerProps { 8 | /** 颜色选择器预设颜色 */ 9 | presets?: string[]; 10 | /** 受控颜色值(例如:#17c964) */ 11 | value?: string; 12 | /** 颜色变更回调(返回十六进制颜色) */ 13 | onChange?: (hex: string) => void; 14 | /** 自定义类名 */ 15 | className?: string; 16 | } 17 | 18 | const ColorPicker: React.FC = ({ presets, value, onChange, className }) => { 19 | return ( 20 | 21 | 22 |
39 | 40 | 41 | ); 42 | }; 43 | 44 | export default ColorPicker; 45 | -------------------------------------------------------------------------------- /src/service/fav-resource-copy.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 批量复制内容 - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要 csrf)或 APP 7 | */ 8 | export interface FavResourceCopyRequestParams { 9 | /** 源收藏夹 id */ 10 | src_media_id: number; 11 | /** 目标收藏夹 id */ 12 | tar_media_id: number; 13 | /** 当前用户 mid */ 14 | mid: number; 15 | /** 目标内容 id 列表,格式:{内容id}:{内容类型},多个用`,`分隔 16 | * 类型:2:视频稿件 12:音频 21:视频合集 17 | * 内容 id:视频稿件 avid / 音频 auid / 视频合集 id 18 | * 例:"21822819:2,21918689:2,22288065:2" 19 | */ 20 | resources: string; 21 | /** 平台标识,可为 web */ 22 | platform?: string; 23 | /** CSRF Token(bili_jct),Cookie 方式必要 */ 24 | csrf: string; 25 | } 26 | 27 | /** 28 | * 批量复制内容 - 响应类型 29 | */ 30 | export interface FavResourceCopyResponse { 31 | /** 返回值 0:成功 -101:未登录 -111:csrf校验失败 -400:请求错误 11010:内容不存在 */ 32 | code: number; 33 | /** 错误信息,默认为 "0" */ 34 | message: string; 35 | /** 固定为 1 */ 36 | ttl: number; 37 | /** 信息本体,成功为 0 */ 38 | data: number; 39 | } 40 | 41 | /** 42 | * 批量复制内容 43 | */ 44 | export function postFavResourceCopy(params: FavResourceCopyRequestParams): Promise { 45 | const form = new URLSearchParams(); 46 | form.set("src_media_id", String(params.src_media_id)); 47 | form.set("tar_media_id", String(params.tar_media_id)); 48 | form.set("mid", String(params.mid)); 49 | form.set("resources", params.resources); 50 | if (params.platform) form.set("platform", params.platform); 51 | form.set("csrf", params.csrf); 52 | return apiRequest.post("/x/v3/fav/resource/copy", form); 53 | } 54 | -------------------------------------------------------------------------------- /src/service/fav-resource-move.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 批量移动内容 - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded) 6 | * 认证方式:Cookie(需要 csrf)或 APP 7 | */ 8 | export interface FavResourceMoveRequestParams { 9 | /** 源收藏夹 id */ 10 | src_media_id: number; 11 | /** 目标收藏夹 id */ 12 | tar_media_id: number; 13 | /** 当前用户 mid */ 14 | mid: number; 15 | /** 目标内容 id 列表,格式:{内容id}:{内容类型},多个用`,`分隔 16 | * 类型:2:视频稿件 12:音频 21:视频合集 17 | * 内容 id:视频稿件 avid / 音频 auid / 视频合集 id 18 | * 例:"21822819:2,21918689:2,22288065:2" 19 | */ 20 | resources: string; 21 | /** 平台标识,可为 web */ 22 | platform?: string; 23 | /** CSRF Token(bili_jct),Cookie 方式必要 */ 24 | csrf: string; 25 | } 26 | 27 | /** 28 | * 批量移动内容 - 响应类型 29 | */ 30 | export interface FavResourceMoveResponse { 31 | /** 返回值 0:成功 -101:未登录 -111:csrf校验失败 -400:请求错误 11010:内容不存在 */ 32 | code: number; 33 | /** 错误信息,默认为 "0" */ 34 | message: string; 35 | /** 固定为 1 */ 36 | ttl: number; 37 | /** 信息本体,成功为 0 */ 38 | data: number; 39 | } 40 | 41 | /** 42 | * 批量移动内容 43 | */ 44 | export function postFavResourceMove(params: FavResourceMoveRequestParams): Promise { 45 | const form = new URLSearchParams(); 46 | form.set("src_media_id", String(params.src_media_id)); 47 | form.set("tar_media_id", String(params.tar_media_id)); 48 | form.set("mid", String(params.mid)); 49 | form.set("resources", params.resources); 50 | if (params.platform) form.set("platform", params.platform); 51 | form.set("csrf", params.csrf); 52 | return apiRequest.post("/x/v3/fav/resource/move", form); 53 | } 54 | -------------------------------------------------------------------------------- /src/service/passport-login-web-cookie-refresh.ts: -------------------------------------------------------------------------------- 1 | import { passportRequest } from "./request"; 2 | 3 | /** 4 | * 刷新 Cookie - 请求参数 5 | * 请求方式:POST(application/x-www-form-urlencoded 或 JSON 直接提交均支持,后端会解析) 6 | * 认证方式:Cookie(需要携带 SESSDATA) 7 | */ 8 | export interface WebCookieRefreshRequestParams { 9 | /** 实时刷新口令(通过对应的 correspond/1/{CorrespondPath} 页面获取) */ 10 | refresh_csrf: string; 11 | /** 访问来源,一般为 "main_web" */ 12 | source: string; 13 | /** 持久化刷新口令(登录成功后返回并存储于 localStorage 的 ac_time_value 字段) */ 14 | refresh_token: string; 15 | } 16 | 17 | /** 18 | * 刷新 Cookie - 顶层响应 19 | */ 20 | export interface WebCookieRefreshResponse { 21 | /** 返回值:0 成功;-101 未登录;-111 csrf 校验失败;86095 refresh_csrf 错误或 refresh_token 与 cookie 不匹配 */ 22 | code: number; 23 | /** 错误信息(默认为 "0") */ 24 | message: string; 25 | /** 固定为 1 */ 26 | ttl: number; 27 | /** 信息本体 */ 28 | data: { 29 | /** 状态,一般为 0 */ 30 | status: number; 31 | /** 信息,一般为空字符串 */ 32 | message: string; 33 | /** 新的持久化刷新口令(需覆盖保存到 localStorage 的 ac_time_value 字段) */ 34 | refresh_token: string; 35 | }; 36 | } 37 | 38 | /** 39 | * 刷新 Cookie 40 | * - 若需要以 x-www-form-urlencoded 方式提交,可以传入 URLSearchParams;若以 JSON 方式提交,直接传对象。 41 | * - axios 在 withCredentials=true 的情况下会自动携带 Cookie(SESSDATA)。 42 | */ 43 | export function postPassportLoginWebCookieRefresh( 44 | data: WebCookieRefreshRequestParams | URLSearchParams, 45 | ): Promise { 46 | return passportRequest.post("/x/passport-login/web/cookie/refresh", data, { 47 | useCSRF: true, 48 | useFormData: true, 49 | skipRefreshCheck: true, 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /electron/windows/thumbar.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain, nativeImage } from "electron"; 2 | import path from "node:path"; 3 | 4 | import { ELECTRON_ICON_BASE_PATH } from "@shared/path"; 5 | 6 | import { channel } from "../ipc/channel"; 7 | import { IconBase } from "../utils"; 8 | 9 | const iconPrev = nativeImage.createFromPath(path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, "prev.png")); 10 | const iconNext = nativeImage.createFromPath(path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, "next.png")); 11 | const iconPlay = nativeImage.createFromPath(path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, "play.png")); 12 | const iconPause = nativeImage.createFromPath(path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, "pause.png")); 13 | 14 | /** 15 | * 在 Windows 上设置任务栏缩略按钮,并根据播放状态动态更新播放/暂停按钮。 16 | * 返回一个清理句柄用于取消事件监听。 17 | */ 18 | export function setupWindowsThumbar(win: BrowserWindow) { 19 | const ensureThumbBar = (isPlaying: boolean) => { 20 | win.setThumbarButtons([ 21 | { 22 | tooltip: "上一首", 23 | icon: iconPrev, 24 | click: () => win.webContents.send(channel.player.prev), 25 | }, 26 | { 27 | tooltip: isPlaying ? "暂停" : "播放", 28 | icon: isPlaying ? iconPause : iconPlay, 29 | click: () => win.webContents.send(channel.player.toggle), 30 | }, 31 | { 32 | tooltip: "下一首", 33 | icon: iconNext, 34 | click: () => win.webContents.send(channel.player.next), 35 | }, 36 | ]); 37 | }; 38 | 39 | // 初始化按钮(默认未播放) 40 | ensureThumbBar(false); 41 | 42 | ipcMain.on(channel.player.state, (_, isPlaying) => { 43 | ensureThumbBar(!!isPlaying); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/layout/playbar/center/progress.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { Slider, type SliderProps } from "@heroui/react"; 4 | 5 | import { formatDuration } from "@/common/utils"; 6 | import { usePlayList } from "@/store/play-list"; 7 | 8 | const Progress = ({ isDisabled }: SliderProps) => { 9 | const [hovered, setHovered] = useState(false); 10 | 11 | const duration = usePlayList(s => s.duration); 12 | const currentTime = usePlayList(s => s.currentTime); 13 | const seek = usePlayList(s => s.seek); 14 | 15 | const showThumb = !isDisabled && hovered; 16 | 17 | return ( 18 |
19 |
20 | {currentTime ? formatDuration(currentTime) : "-:--"} 21 |
22 | seek(v as number)} 29 | isDisabled={isDisabled} 30 | size="sm" 31 | color={showThumb ? "primary" : "foreground"} 32 | onMouseEnter={() => setHovered(true)} 33 | onMouseLeave={() => setHovered(false)} 34 | className="flex-1" 35 | classNames={{ 36 | track: "h-[4px] cursor-pointer", 37 | thumb: "w-4 h-4 bg-primary after:hidden", 38 | }} 39 | /> 40 | 41 | {duration ? formatDuration(duration) : "-:--"} 42 | 43 |
44 | ); 45 | }; 46 | 47 | export default Progress; 48 | -------------------------------------------------------------------------------- /src/layout/side/logo/index.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Tooltip, useDisclosure } from "@heroui/react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | import { ReactComponent as LogoIcon } from "@/assets/icons/logo.svg"; 5 | import ReleaseNoteModal from "@/components/release-note-modal"; 6 | import { useAppUpdateStore } from "@/store/app-update"; 7 | 8 | const isMac = window.electron?.getPlatform() === "macos"; 9 | 10 | const Logo = () => { 11 | const { isOpen, onOpen, onOpenChange } = useDisclosure(); 12 | const isUpdateAvailable = useAppUpdateStore(s => s.isUpdateAvailable); 13 | 14 | return ( 15 | <> 16 |
22 | 23 | {isUpdateAvailable ? ( 24 | 33 | 34 |
35 | Biu 36 |
37 |
38 |
39 | ) : ( 40 | Biu 41 | )} 42 |
43 | 44 | 45 | ); 46 | }; 47 | 48 | export default Logo; 49 | -------------------------------------------------------------------------------- /electron/utils.ts: -------------------------------------------------------------------------------- 1 | import isDev from "electron-is-dev"; 2 | import log from "electron-log"; 3 | import ffmpeg from "fluent-ffmpeg"; 4 | import fs from "node:fs"; 5 | import path from "node:path"; 6 | 7 | import { ELECTRON_ICON_BASE_PATH } from "@shared/path"; 8 | 9 | import { appSettingsStore } from "./store"; 10 | 11 | export const IconBase = isDev ? process.cwd() : process.resourcesPath; 12 | 13 | export const getWindowIcon = () => 14 | process.platform === "darwin" 15 | ? undefined 16 | : path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, process.platform === "win32" ? "logo.ico" : "logo.png"); 17 | 18 | export const getMacLightIconPath = () => path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, "light-icon.png"); 19 | 20 | export const getMacDarkIconPath = () => path.resolve(IconBase, ELECTRON_ICON_BASE_PATH, "dark-icon.png"); 21 | 22 | export const fixFfmpegPath = () => { 23 | try { 24 | const settings = appSettingsStore.get("appSettings"); 25 | if (settings?.ffmpegPath && fs.existsSync(settings.ffmpegPath)) { 26 | log.info(`Found user configured ffmpeg at ${settings.ffmpegPath}`); 27 | ffmpeg.setFfmpegPath(settings.ffmpegPath); 28 | return; 29 | } 30 | } catch (err) { 31 | log.error("Error reading ffmpeg path from settings:", err); 32 | } 33 | 34 | if (process.platform === "darwin" || process.platform === "linux") { 35 | const paths = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg", "/snap/bin/ffmpeg"]; 36 | for (const p of paths) { 37 | if (fs.existsSync(p)) { 38 | log.info(`Found ffmpeg at ${p}`); 39 | ffmpeg.setFfmpegPath(p); 40 | return; 41 | } 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /electron/ipc/download/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | import type { DashAudio } from "../api/types"; 4 | 5 | import { getWebInterfaceView } from "../api/web-interface-view"; 6 | 7 | const audioQualitySort = [30257, 30216, 30259, 30260, 30232, 30280, 30250, 30251]; 8 | 9 | export function sortAudio(audio: DashAudio[]) { 10 | return audio.toSorted((a, b) => { 11 | if (a.bandwidth !== b.bandwidth) { 12 | return b.bandwidth - a.bandwidth; 13 | } 14 | 15 | const indexA = audioQualitySort.indexOf(a.id); 16 | const indexB = audioQualitySort.indexOf(b.id); 17 | if (indexA === -1) return 1; 18 | if (indexB === -1) return -1; 19 | return indexB - indexA; 20 | }); 21 | } 22 | 23 | /** 24 | * 获取B站历史音乐的音频带宽 25 | * @param type -1 试听片段;0 128K;1 192K;2 320K;3 FLAC 26 | * @returns 音频带宽 27 | */ 28 | export const getStreamAudioBandwidth = (type: number) => { 29 | switch (type) { 30 | case 0: 31 | return 128000; 32 | case 1: 33 | return 192000; 34 | case 2: 35 | return 320000; 36 | default: 37 | return 0; 38 | } 39 | }; 40 | 41 | export const ensureDir = (dir: string) => { 42 | if (!fs.existsSync(dir)) { 43 | fs.mkdirSync(dir, { recursive: true }); 44 | } 45 | }; 46 | 47 | export const removeDirOrFile = (fsPath: string) => { 48 | fs.rmSync(fsPath, { recursive: true, force: true }); 49 | }; 50 | 51 | export const getVideoPages = async (bvid: string) => { 52 | const res = await getWebInterfaceView({ bvid }); 53 | return ( 54 | res?.data?.pages?.map(page => ({ 55 | bvid, 56 | cid: page.cid, 57 | title: page.part, 58 | cover: page.first_frame, 59 | })) || [] 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/service/space-wbi-acc-relation.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 查询用户与自己关系(互相关系)/x/space/wbi/acc/relation - 请求参数 5 | * 请求方式:GET 6 | * 认证方式:Cookie(SESSDATA)或 APP;仅本接口需要 WBI 签名 7 | */ 8 | export interface SpaceWbiAccRelationRequestParams { 9 | /** 目标用户 mid(必要) */ 10 | mid: number; 11 | } 12 | 13 | /** 14 | * 关系属性对象(最小字段集) 15 | * 说明:与文档中的“关系属性对象”一致,仅包含与关系相关的核心字段 16 | */ 17 | export interface RelationAttribute { 18 | /** 目标用户 mid */ 19 | mid: number; 20 | /** 关系属性:0 未关注;1 悄悄关注(已弃用);2 已关注;6 已互粉;128 已拉黑 */ 21 | attribute: number; 22 | /** 关注对方时间(秒级时间戳;未关注为 0) */ 23 | mtime: number; 24 | /** 分组 id 列表(默认分组为 null) */ 25 | tag: number[] | null; 26 | /** 特别关注标志:0 否;1 是 */ 27 | special: number; 28 | } 29 | 30 | /** 31 | * 查询用户与自己关系(互相关系) - data 32 | */ 33 | export interface SpaceWbiAccRelationData { 34 | /** 目标用户对于当前用户的关系 */ 35 | relation: RelationAttribute; 36 | /** 当前用户对于目标用户的关系 */ 37 | be_relation: RelationAttribute; 38 | } 39 | 40 | /** 41 | * 查询用户与自己关系(互相关系) - 顶层响应 42 | */ 43 | export interface SpaceWbiAccRelationResponse { 44 | /** 返回值:0 成功;-101 账号未登录;-400 请求错误 */ 45 | code: number; 46 | /** 错误信息(成功时一般为 "0") */ 47 | message: string; 48 | /** 固定为 1 */ 49 | ttl: number; 50 | /** 信息本体 */ 51 | data: SpaceWbiAccRelationData; 52 | } 53 | 54 | /** 55 | * 查询用户与自己关系(互相关系) 56 | * - 认证:Cookie(SESSDATA)或 APP 57 | * - 签名:WBI(内部由请求器在 useWbi: true 时自动加签) 58 | */ 59 | export function getSpaceWbiAccRelation(params: SpaceWbiAccRelationRequestParams): Promise { 60 | return apiRequest.get("/x/space/wbi/acc/relation", { 61 | params, 62 | useWbi: true, 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /electron/ipc/dialog.ts: -------------------------------------------------------------------------------- 1 | import type { IpcMainInvokeEvent } from "electron"; 2 | 3 | import { ipcMain, shell, dialog } from "electron"; 4 | import log from "electron-log"; 5 | import path from "node:path"; 6 | 7 | import { appSettingsStore, storeKey } from "../store"; 8 | import { channel } from "./channel"; 9 | 10 | export function registerDialogHandlers() { 11 | ipcMain.handle(channel.dialog.openDirectory, async (_event: IpcMainInvokeEvent, dir?: string) => { 12 | const targetDir: string = 13 | dir ?? appSettingsStore.get(storeKey.appSettings)?.downloadPath ?? path.resolve(process.cwd(), "downloads"); 14 | const err = await shell.openPath(targetDir); 15 | return err === ""; 16 | }); 17 | 18 | ipcMain.handle(channel.dialog.openExternal, async (_event: IpcMainInvokeEvent, url: string) => { 19 | try { 20 | await shell.openExternal(url); 21 | return true; 22 | } catch (err) { 23 | // 修改说明:外部链接打开失败时记录错误并返回失败 24 | log.error("[dialog] openExternal failed:", err); 25 | return false; 26 | } 27 | }); 28 | 29 | ipcMain.handle(channel.dialog.selectDirectory, async () => { 30 | const result = await dialog.showOpenDialog({ 31 | properties: ["openDirectory", "createDirectory"], 32 | title: "选择下载保存目录", 33 | }); 34 | if (result.canceled) return null; 35 | const dir = result.filePaths?.[0] ?? null; 36 | return dir; 37 | }); 38 | 39 | ipcMain.handle(channel.dialog.selectFile, async () => { 40 | const result = await dialog.showOpenDialog({ 41 | properties: ["openFile"], 42 | title: "选择文件", 43 | }); 44 | if (result.canceled) return null; 45 | return result.filePaths?.[0] ?? null; 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/service/user-video-archives-list.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | interface Data { 4 | info: Info; 5 | medias: Media[]; 6 | } 7 | interface Media { 8 | id: number; 9 | title: string; 10 | cover: string; 11 | duration: number; 12 | pubtime: number; 13 | bvid: string; 14 | upper: Upper; 15 | cnt_info: Cntinfo; 16 | enable_vt: number; 17 | vt_display: string; 18 | is_self_view: boolean; 19 | } 20 | interface Info { 21 | id: number; 22 | season_type: number; 23 | title: string; 24 | cover: string; 25 | upper: Upper; 26 | cnt_info: Cntinfo; 27 | media_count: number; 28 | intro: string; 29 | enable_vt: number; 30 | } 31 | interface Cntinfo { 32 | collect: number; 33 | play: number; 34 | danmaku: number; 35 | vt: number; 36 | } 37 | interface Upper { 38 | mid: number; 39 | name: string; 40 | } 41 | 42 | /** 43 | * 获取视频合集(seasons_archives)信息 - 顶层响应 44 | */ 45 | export interface PolymerSeasonsArchivesListResponse { 46 | /** 返回值:0 成功;-352 请求被风控;-400 请求错误 */ 47 | code: number; 48 | /** 错误信息(成功时一般为 "0") */ 49 | message: string; 50 | /** 固定为 1 */ 51 | ttl: number; 52 | /** 信息本体 */ 53 | data: Data; 54 | } 55 | 56 | export interface PolymerSeasonsArchivesListRequestParams { 57 | season_id: number; 58 | pn?: number; 59 | ps?: number; 60 | /** 0.0 */ 61 | web_location?: string; 62 | } 63 | 64 | /** 65 | * 获取视频合集详情 66 | */ 67 | export function getUserVideoArchivesList( 68 | params: PolymerSeasonsArchivesListRequestParams, 69 | ): Promise { 70 | return apiRequest.get("/x/space/fav/season/list", { 71 | params, 72 | useWbi: true, 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /src/pages/video-collection/utils.ts: -------------------------------------------------------------------------------- 1 | import { getFavResourceList, type FavResourceListRequestParams } from "@/service/fav-resource"; 2 | 3 | /** 获取收藏夹中的所有媒体 */ 4 | export const getAllFavMedia = async ( 5 | { id: favFolderId, totalCount }: { id: string; totalCount: number }, 6 | searchParams?: Pick, 7 | ) => { 8 | const FAVORITES_PAGE_SIZE = 20; 9 | const allResSettled = await Promise.allSettled( 10 | Array.from({ length: Math.ceil(totalCount / FAVORITES_PAGE_SIZE) }, (_, i) => 11 | getFavResourceList({ 12 | media_id: String(favFolderId), 13 | ps: FAVORITES_PAGE_SIZE, 14 | pn: i + 1, 15 | platform: "web", 16 | ...searchParams, 17 | }), 18 | ), 19 | ); 20 | 21 | return allResSettled 22 | .filter(res => res.status === "fulfilled") 23 | .map(res => res.value) 24 | .filter(res => res.code === 0 && res?.data?.medias?.length) 25 | .flatMap(res => 26 | res.data.medias 27 | .filter(item => item.attr === 0) 28 | .map(item => { 29 | if (item.type === 2) { 30 | return { 31 | type: "mv" as const, 32 | bvid: item.bvid, 33 | title: item.title, 34 | cover: item.cover, 35 | ownerMid: item.upper?.mid, 36 | ownerName: item.upper?.name, 37 | }; 38 | } 39 | return { 40 | type: "audio" as const, 41 | sid: item.id, 42 | title: item.title, 43 | cover: item.cover, 44 | ownerMid: item.upper?.mid, 45 | ownerName: item.upper?.name, 46 | }; 47 | }), 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | class FakeMediaSession { 2 | metadata: any; 3 | playbackState: string = "none"; 4 | setActionHandler() {} 5 | setPositionState() {} 6 | } 7 | 8 | class FakeMediaMetadata { 9 | constructor(public data: any) {} 10 | } 11 | 12 | class FakeAudio { 13 | preload: string = "none"; 14 | controls: boolean = false; 15 | crossOrigin: string = "anonymous"; 16 | volume: number = 1; 17 | muted: boolean = false; 18 | playbackRate: number = 1; 19 | loop: boolean = false; 20 | src: string = ""; 21 | currentTime: number = 0; 22 | duration: number = 0; 23 | paused: boolean = true; 24 | ondurationchange?: () => void; 25 | ontimeupdate?: () => void; 26 | onseeked?: () => void; 27 | onratechange?: () => void; 28 | onplay?: () => void; 29 | onpause?: () => void; 30 | onended?: () => void; 31 | onerror?: (err: any) => void; 32 | load() { 33 | this.duration = 123; 34 | if (typeof this.ondurationchange === "function") this.ondurationchange(); 35 | } 36 | async play() { 37 | this.paused = false; 38 | if (typeof this.onplay === "function") this.onplay(); 39 | if (typeof this.ontimeupdate === "function") this.ontimeupdate(); 40 | } 41 | pause() { 42 | this.paused = true; 43 | if (typeof this.onpause === "function") this.onpause(); 44 | } 45 | } 46 | 47 | const g: any = globalThis; 48 | const nav: any = g.navigator ?? {}; 49 | if (!nav.mediaSession) { 50 | nav.mediaSession = new FakeMediaSession(); 51 | } 52 | if (typeof g.navigator === "undefined") { 53 | Object.defineProperty(g, "navigator", { value: nav, configurable: true }); 54 | } 55 | g.MediaMetadata = FakeMediaMetadata; 56 | g.window = g; 57 | g.window.electron = undefined; 58 | g.Audio = FakeAudio as any; 59 | -------------------------------------------------------------------------------- /src/service/request/request-interceptors.ts: -------------------------------------------------------------------------------- 1 | import { type InternalAxiosRequestConfig } from "axios"; 2 | import moment from "moment"; 3 | 4 | import { refreshCookie } from "@/common/utils/cookie"; 5 | import { useToken } from "@/store/token"; 6 | 7 | import { encodeParamsWbi } from "./wbi-sign"; 8 | 9 | let refreshCookiePromise: Promise | null = null; 10 | 11 | export const requestInterceptors = async (config: InternalAxiosRequestConfig) => { 12 | if (!config.skipRefreshCheck && (useToken.getState().nextCheckRefreshTime || 0) < moment().unix()) { 13 | if (!refreshCookiePromise) { 14 | useToken.setState({ nextCheckRefreshTime: moment().add(30, "seconds").unix() }); 15 | refreshCookiePromise = refreshCookie().finally(() => { 16 | refreshCookiePromise = null; 17 | }); 18 | } 19 | try { 20 | await refreshCookiePromise; 21 | } finally { 22 | useToken.setState({ nextCheckRefreshTime: moment().add(2, "days").unix() }); 23 | } 24 | } 25 | 26 | if (config.useCSRF) { 27 | const csrfToken = await window.electron.getCookie("bili_jct"); 28 | if (csrfToken) { 29 | if (config.method === "post") { 30 | config.data ??= {}; 31 | config.data.csrf = csrfToken; 32 | } else { 33 | config.params ??= {}; 34 | config.params.csrf = csrfToken; 35 | } 36 | } 37 | } 38 | 39 | if (config.useWbi) { 40 | config.params ??= {}; 41 | config.params = await encodeParamsWbi(config.params); 42 | } 43 | 44 | if (config.useFormData) { 45 | const formData = new FormData(); 46 | for (const key in config.data) { 47 | formData.append(key, config.data[key]); 48 | } 49 | config.data = formData; 50 | } 51 | 52 | return config; 53 | }; 54 | -------------------------------------------------------------------------------- /electron/ipc/store.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "electron"; 2 | import log from "electron-log"; 3 | 4 | import { appSettingsStore, userStore, mediaDownloadsStore } from "../store"; 5 | import { channel } from "./channel"; 6 | 7 | export function registerStoreHandlers() { 8 | ipcMain.handle(channel.store.get, async (_, name: StoreName) => { 9 | if (name === "app-settings") { 10 | return appSettingsStore.store; 11 | } 12 | 13 | if (name === "user-login-info") { 14 | return userStore.store; 15 | } 16 | 17 | if (name === "media-downloads") { 18 | return mediaDownloadsStore.store; 19 | } 20 | }); 21 | 22 | ipcMain.handle(channel.store.set, async (_, name: StoreName, value: any) => { 23 | try { 24 | // 确保 value 是有效对象,防止 electron-store 报错 25 | if (value === null || value === undefined) { 26 | log.warn(`[store:set] Received invalid value for ${name}:`, value); 27 | return; 28 | } 29 | 30 | if (name === "app-settings") { 31 | appSettingsStore.set(value); 32 | } 33 | 34 | if (name === "user-login-info") { 35 | userStore.set(value); 36 | } 37 | 38 | if (name === "media-downloads") { 39 | mediaDownloadsStore.set(value); 40 | } 41 | } catch (err) { 42 | log.error(`[store:set] Error setting store ${name}:`, err); 43 | } 44 | }); 45 | 46 | ipcMain.handle(channel.store.clear, async (_, name: StoreName) => { 47 | if (name === "app-settings") { 48 | appSettingsStore.clear(); 49 | } 50 | 51 | if (name === "user-login-info") { 52 | userStore.clear(); 53 | } 54 | 55 | if (name === "media-downloads") { 56 | mediaDownloadsStore.clear(); 57 | } 58 | 59 | return true; 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /src/service/web-bili-ticket.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | /** 4 | * 生成 Web bili_ticket - 请求参数 5 | * POST /bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket 6 | * 说明:hexsign 使用 HMAC-SHA256(key="XgwSnGZ1p", message=`ts${timestamp}`) 的十六进制字符串 7 | * 其他:csrf 可为空;Referer 可为空或 .bilibili.com 子域 8 | */ 9 | export interface GenWebTicketRequestParams { 10 | /** 固定为 ec02 */ 11 | key_id: "ec02"; 12 | /** HMAC-SHA256 十六进制签名(message: "ts" + timestamp) */ 13 | hexsign: string; 14 | /** UNIX 秒级时间戳(作为 context[ts]) */ 15 | ["context[ts]"]: number; 16 | /** Cookie 中的 bili_jct,可选 */ 17 | csrf?: string; 18 | } 19 | 20 | /** nav 信息(用于 WBI 签名相关) */ 21 | export interface GenWebTicketNav { 22 | /** img_key 值 */ 23 | img: string; 24 | /** sub_key 值 */ 25 | sub: string; 26 | } 27 | 28 | /** data 本体 */ 29 | export interface GenWebTicketData { 30 | /** 生成的 bili_ticket(JWT) */ 31 | ticket: string; 32 | /** 创建时间(UNIX 秒) */ 33 | created_at: number; 34 | /** 有效时长(秒),一般为 259200(3 天) */ 35 | ttl: number; 36 | /** 上下文(保留扩展) */ 37 | context: Record; 38 | /** WBI 相关的 img/sub */ 39 | nav: GenWebTicketNav; 40 | } 41 | 42 | /** 顶层响应 */ 43 | export interface GenWebTicketResponse { 44 | /** 返回值:0 成功;400 参数错误 */ 45 | code: number; 46 | /** 返回消息,如 "OK" */ 47 | message: string; 48 | /** 固定为 1 */ 49 | ttl: number; 50 | /** 数据本体 */ 51 | data: GenWebTicketData; 52 | } 53 | 54 | /** 55 | * 生成 Web bili_ticket 56 | * POST /bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket 57 | */ 58 | export function postGenWebTicket(params: GenWebTicketRequestParams): Promise { 59 | return axios.post( 60 | "https://api.bilibili.com/bapis/bilibili.api.ticket.v1.Ticket/GenWebTicket", 61 | null, 62 | { params }, 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/image-card/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardBody, CardFooter, Image } from "@heroui/react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | import FallbackImage from "@/assets/images/fallback.png"; 5 | import { formatUrlProtocal } from "@/common/utils/url"; 6 | 7 | import Skeleton from "./skeleton"; 8 | 9 | export interface ImageCardProps { 10 | imageUrl: string; 11 | title: React.ReactNode; 12 | imageHeight?: number; 13 | imageMask?: React.ReactNode; 14 | bodyClassName?: string; 15 | titleExtra?: React.ReactNode; 16 | footer?: React.ReactNode; 17 | onPress?: () => void; 18 | } 19 | 20 | const ImageCard = ({ 21 | imageUrl, 22 | imageHeight = 188, 23 | bodyClassName, 24 | imageMask, 25 | title, 26 | titleExtra, 27 | footer, 28 | onPress, 29 | }: ImageCardProps) => { 30 | return ( 31 | 32 | 33 | 42 | {imageMask} 43 | 44 | 45 |
46 |
{title}
47 | {titleExtra} 48 |
49 | {footer} 50 |
51 |
52 | ); 53 | }; 54 | 55 | ImageCard.Skeleton = Skeleton; 56 | 57 | export default ImageCard; 58 | -------------------------------------------------------------------------------- /src/service/fav-folder-created-list.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 获取指定用户创建的所有收藏夹信息 - 请求参数 5 | * 请求方式:GET 6 | * 认证方式:Cookie(SESSDATA) 或 APP 7 | */ 8 | export interface FavFolderCreatedListRequestParams { 9 | /** 目标用户 mid */ 10 | up_mid: number; 11 | /** 每页数量,默认 20,最大 50 */ 12 | ps?: number; 13 | /** 当前页码,默认 1 */ 14 | pn?: number; 15 | /** web 位置标识,如 333.1387 */ 16 | web_location?: string; 17 | } 18 | 19 | /** 20 | * 获取指定用户创建的所有收藏夹信息 - 响应对象 21 | */ 22 | export interface FavFolderCreatedListResponse { 23 | /** 返回值 0:成功 -400:请求错误 */ 24 | code: number; 25 | /** 错误信息,默认为 "0" */ 26 | message: string; 27 | /** 固定为 1 */ 28 | ttl: number; 29 | /** 信息本体,目标用户未公开时为 null,公开时为对象 */ 30 | data: FavFolderCreatedListData | null; 31 | } 32 | 33 | interface FavFolderCreatedListData { 34 | count: number; 35 | list: List[]; 36 | has_more: boolean; 37 | } 38 | 39 | interface List { 40 | id: number; 41 | fid: number; 42 | mid: number; 43 | attr: number; 44 | attr_desc: string; 45 | title: string; 46 | cover: string; 47 | upper: Upper; 48 | cover_type: number; 49 | intro: string; 50 | ctime: number; 51 | mtime: number; 52 | state: number; 53 | fav_state: number; 54 | media_count: number; 55 | view_count: number; 56 | vt: number; 57 | is_top: boolean; 58 | recent_fav?: any; 59 | play_switch: number; 60 | type: number; 61 | link: string; 62 | bvid: string; 63 | } 64 | 65 | interface Upper { 66 | mid: number; 67 | name: string; 68 | face: string; 69 | jump_link: string; 70 | } 71 | 72 | /** 73 | * 获取指定用户创建的所有收藏夹信息 74 | * @param params 请求参数 75 | * @returns Promise 76 | */ 77 | export const getFavFolderCreatedList = (params: FavFolderCreatedListRequestParams) => { 78 | return apiRequest.get("/x/v3/fav/folder/created/list", { params }); 79 | }; 80 | -------------------------------------------------------------------------------- /src/service/history-toview-list.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | export interface HistoryToViewListParams { 4 | /** 分页参数:当前页码,默认 1 */ 5 | pn?: number; 6 | /** 分页参数:每页数量,默认 20,最大 50 */ 7 | ps?: number; 8 | /** 0: 全部;2:未看完 */ 9 | viewed?: number; 10 | } 11 | 12 | /** 稍后再看视频条目(字段与 web 接口一致,尽量精确建模) */ 13 | export interface ToViewVideoItem { 14 | aid: number; // 稿件avid 15 | videos: number; // 分P总数 16 | tid: number; // 分区tid 17 | tname: string; // 子分区名称 18 | copyright: number; // 1原创 2转载 19 | pic: string; // 封面 20 | title: string; // 标题 21 | pubdate: number; // 发布时间戳 22 | ctime: number; // 用户提交时间戳 23 | desc: string; // 简介 24 | state: number; // 状态(详见 web-interface-view.ts 中 state 备注) 25 | /** 26 | * 历史保留字段 attribute(文档称该字段已删除,接口偶尔仍可见,这里不强制) 27 | * 若后端实际不返回可为 undefined 28 | */ 29 | attribute?: number; 30 | duration: number; // 总时长(秒) 31 | rights: import("./web-interface-view").Rights; // 权限信息 32 | owner: import("./web-interface-view").Owner; // UP 主 33 | stat: import("./web-interface-view").Stat; // 状态数 34 | dynamic: string; // 动态文字 35 | dimension: import("./web-interface-view").Dimension; // 1P 分辨率 36 | /** 非投稿可能没有该字段 */ 37 | count?: number; 38 | cid: number; // 视频cid 39 | progress: number; // 观看进度(秒) 40 | add_at: number; // 添加时间戳 41 | bvid: string; // 稿件bvid 42 | } 43 | 44 | export interface HistoryToViewListResponse { 45 | code: number; // 0 成功 -101 未登录 -400 请求错误 46 | message: string; // 错误信息 47 | ttl: number; // 1 48 | data: { 49 | count: number; // 稍后再看视频数 50 | list: ToViewVideoItem[]; // 列表 51 | }; 52 | } 53 | 54 | /** 55 | * 获取稍后再看视频列表 - GET /x/v2/history/toview/web 56 | */ 57 | export async function getHistoryToViewList(params: HistoryToViewListParams): Promise { 58 | return apiRequest.get("/x/v2/history/toview/web", { 59 | params, 60 | useWbi: true, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/service/space-navnum.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 用户导航栏状态数(/x/space/navnum) - 请求参数 5 | * 请求方式:GET 6 | */ 7 | export interface SpaceNavnumRequestParams { 8 | /** 目标用户 mid(必要) */ 9 | mid: number; 10 | /** 额外定位(可选) */ 11 | web_location?: string; 12 | } 13 | 14 | /** 15 | * 用户导航栏状态数(/x/space/navnum) - channel 16 | */ 17 | export interface SpaceNavnumChannel { 18 | /** 视频列表数(全部) */ 19 | master: number; 20 | /** 视频列表数(公开) */ 21 | guest: number; 22 | } 23 | 24 | /** 25 | * 用户导航栏状态数(/x/space/navnum) - favourite 26 | */ 27 | export interface SpaceNavnumFavourite { 28 | /** 全部收藏夹数(需登录,仅自己可见) */ 29 | master: number; 30 | /** 公开收藏夹数 */ 31 | guest: number; 32 | } 33 | 34 | /** 35 | * 用户导航栏状态数(/x/space/navnum) - data 36 | */ 37 | export interface SpaceNavnumData { 38 | /** 投稿视频数 */ 39 | video: number; 40 | /** 追番数(无视隐私设置) */ 41 | bangumi: number; 42 | /** 追剧数(无视隐私设置) */ 43 | cinema: number; 44 | /** 视频列表数 */ 45 | channel: SpaceNavnumChannel; 46 | /** 收藏夹数 */ 47 | favourite: SpaceNavnumFavourite; 48 | /** 关注 TAG 数(无视隐私设置) */ 49 | tag: number; 50 | /** 投稿专栏数 */ 51 | article: number; 52 | /** 作用尚不明确 */ 53 | playlist: number; 54 | /** 投稿图文数 */ 55 | album: number; 56 | /** 投稿音频数 */ 57 | audio: number; 58 | /** 投稿课程数 */ 59 | pugv: number; 60 | /** 视频合集数 */ 61 | season_num: number; 62 | /** 动态数(有的文档叫 opus) */ 63 | opus?: number; 64 | } 65 | 66 | /** 67 | * 用户导航栏状态数(/x/space/navnum) - 顶层响应 68 | */ 69 | export interface SpaceNavnumResponse { 70 | /** 返回值 0:成功;-400:请求错误 */ 71 | code: number; 72 | /** 错误信息,默认为 "0" */ 73 | message: string; 74 | /** 固定为 1 */ 75 | ttl: number; 76 | /** 信息本体 */ 77 | data: SpaceNavnumData; 78 | } 79 | 80 | /** 81 | * 查询用户导航栏状态数 82 | */ 83 | export function getSpaceNavnum(params: SpaceNavnumRequestParams): Promise { 84 | return apiRequest.get("/x/space/navnum", { params }); 85 | } 86 | -------------------------------------------------------------------------------- /electron/ipc/download/ffmpeg-processor.ts: -------------------------------------------------------------------------------- 1 | import log from "electron-log"; 2 | import ffmpeg from "fluent-ffmpeg"; 3 | 4 | import { fixFfmpegPath } from "../../utils"; 5 | 6 | interface ConvertOptions { 7 | outputFileType: MediaDownloadOutputFileType; 8 | audioTempPath: string; 9 | videoTempPath?: string; 10 | outputPath: string; 11 | onProgress?: (percent: number) => void; 12 | } 13 | 14 | export const convert = async ({ 15 | audioTempPath, 16 | videoTempPath, 17 | outputPath, 18 | outputFileType, 19 | onProgress, 20 | }: ConvertOptions): Promise => { 21 | fixFfmpegPath(); 22 | return new Promise((resolve, reject) => { 23 | log.info(`Starting conversion: Audio=${audioTempPath}, Video=${videoTempPath ?? "N/A"} -> ${outputPath}`); 24 | 25 | const command = ffmpeg(); 26 | 27 | // Set overwrite output option 28 | command.outputOptions("-y"); 29 | 30 | if (outputFileType === "video") { 31 | if (!videoTempPath || !audioTempPath) { 32 | return reject(new Error("Video conversion requires both video and audio temp paths")); 33 | } 34 | // ffmpeg -i video -i audio -c:v copy -c:a copy -shortest output 35 | command.input(videoTempPath); 36 | command.input(audioTempPath); 37 | command.outputOptions(["-c:v copy", "-c:a copy", "-shortest"]); 38 | } else { 39 | // ffmpeg -i audio -c:a copy output 40 | command.input(audioTempPath); 41 | command.outputOptions(["-c:a copy"]); 42 | } 43 | 44 | command.on("progress", progress => { 45 | if (onProgress && progress.percent) { 46 | onProgress(Math.round(progress.percent * 100) / 100); 47 | } 48 | }); 49 | 50 | command.on("error", err => { 51 | log.error("Cannot process video: " + err.message); 52 | reject(err); 53 | }); 54 | 55 | command.on("end", () => { 56 | resolve(); 57 | }); 58 | 59 | // Save to output path 60 | command.save(outputPath); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/service/fav-folder-created-list-all.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 获取指定用户创建的所有收藏夹信息 - 请求参数 5 | * 请求方式:GET 6 | * 认证方式:Cookie(SESSDATA) 或 APP 7 | */ 8 | export interface FavFolderCreatedListAllRequestParams { 9 | /** 目标用户 mid */ 10 | up_mid: number; 11 | /** 目标内容属性,默认为全部 12 | * 0:全部 13 | * 2:视频稿件 14 | */ 15 | type?: number; 16 | /** 目标内容 id(视频稿件:avid) */ 17 | rid?: string | number; 18 | /** web 位置标识,如 333.1387 */ 19 | web_location?: string; 20 | } 21 | 22 | /** 23 | * 获取指定用户创建的所有收藏夹信息 - 响应对象 24 | */ 25 | export interface FavFolderCreatedListAllResponse { 26 | /** 返回值 0:成功 -400:请求错误 */ 27 | code: number; 28 | /** 错误信息,默认为 "0" */ 29 | message: string; 30 | /** 固定为 1 */ 31 | ttl: number; 32 | /** 信息本体,目标用户未公开时为 null,公开时为对象 */ 33 | data: FavFolderCreatedListAllData | null; 34 | } 35 | 36 | /** 37 | * 获取指定用户创建的所有收藏夹信息 - data 38 | */ 39 | export interface FavFolderCreatedListAllData { 40 | /** 创建的收藏夹数 */ 41 | count: number; 42 | /** 收藏夹列表,没有收藏夹时为 null */ 43 | list: FavFolderItem[] | null; 44 | /** 文档标注为 null(占位字段) */ 45 | season: null; 46 | } 47 | 48 | /** 49 | * 收藏夹条目 50 | */ 51 | export interface FavFolderItem { 52 | /** 收藏夹 mlid(完整 id),由原始 id + 创建者 mid 尾号 2 位组成 */ 53 | id: number; 54 | /** 收藏夹原始 id */ 55 | fid: number; 56 | /** 创建者 mid */ 57 | mid: number; 58 | /** 收藏夹属性二进制位 59 | * 位0:私有收藏夹(0:公开 1:私有) 60 | * 位1:是否为默认收藏夹(0:默认收藏夹 1:其他收藏夹) 61 | */ 62 | attr: number; 63 | /** 收藏夹标题 */ 64 | title: string; 65 | /** 目标 id 是否存在于该收藏夹(存在:1,不存在:0) */ 66 | fav_state: number; 67 | /** 收藏夹内容数量 */ 68 | media_count: number; 69 | } 70 | 71 | /** 72 | * 获取指定用户创建的所有收藏夹信息 73 | * @param params 请求参数 74 | * @returns Promise 75 | */ 76 | export const getFavFolderCreatedListAll = (params: FavFolderCreatedListAllRequestParams) => { 77 | return apiRequest.get("/x/v3/fav/folder/created/list-all", { params }); 78 | }; 79 | -------------------------------------------------------------------------------- /src/layout/playbar/left/video-page-list/menu.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure } from "@heroui/react"; 2 | import { RiDeleteBinLine, RiExternalLinkLine, RiMoreFill } from "@remixicon/react"; 3 | 4 | import { openBiliVideoLink } from "@/common/utils/url"; 5 | import { usePlayList, type PlayData } from "@/store/play-list"; 6 | 7 | interface Props { 8 | data: PlayData; 9 | } 10 | 11 | const Menus = ({ data }: Props) => { 12 | const delPage = usePlayList(state => state.delPage); 13 | const { isOpen, onOpenChange } = useDisclosure(); 14 | 15 | return ( 16 | <> 17 | 25 | 26 | 34 | 35 | 36 | } 39 | onPress={() => { 40 | openBiliVideoLink(data); 41 | }} 42 | > 43 | 在 B 站打开 44 | 45 | } 49 | onPress={() => delPage(data.id)} 50 | > 51 | 从列表删除 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default Menus; 60 | -------------------------------------------------------------------------------- /src/components/select-all-checkbox-group/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Checkbox, CheckboxGroup } from "@heroui/react"; 4 | 5 | interface SelectAllCheckboxGroupProps { 6 | groupName: string; 7 | groupKeys: string[]; 8 | selectedKeys: string[]; 9 | onSelectionChange: (keys: string[]) => void; 10 | disabled?: boolean; 11 | items: Array<{ value: string; label: string }>; 12 | } 13 | 14 | const SelectAllCheckboxGroup: React.FC = ({ 15 | groupName, 16 | groupKeys, 17 | selectedKeys, 18 | onSelectionChange, 19 | disabled = false, 20 | items, 21 | }) => { 22 | const isSelectAll = groupKeys.length > 0 && selectedKeys.length === groupKeys.length; 23 | 24 | const handleSelectAllChange = (checked: boolean) => { 25 | onSelectionChange(checked ? groupKeys : []); 26 | }; 27 | 28 | const handleCheckboxGroupChange = (keys: string[]) => { 29 | onSelectionChange(keys); 30 | }; 31 | 32 | return ( 33 |
34 |
35 | 42 | 全选 43 | 44 |
45 | 56 | {items.map(item => ( 57 | 58 | {item.label} 59 | 60 | ))} 61 | 62 |
63 | ); 64 | }; 65 | 66 | export default SelectAllCheckboxGroup; 67 | -------------------------------------------------------------------------------- /src/pages/music-rank/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRequest } from "ahooks"; 2 | 3 | import GridList from "@/components/grid-list"; 4 | import MediaItem from "@/components/media-item"; 5 | import ScrollContainer from "@/components/scroll-container"; 6 | import { getMusicHotRank } from "@/service/music-hot-rank"; 7 | import { usePlayList } from "@/store/play-list"; 8 | import { useSettings } from "@/store/settings"; 9 | 10 | const MusicRank = () => { 11 | const play = usePlayList(s => s.play); 12 | const displayMode = useSettings(state => state.displayMode); 13 | 14 | const { loading, data } = useRequest(async () => { 15 | const res = await getMusicHotRank({ 16 | plat: 2, 17 | web_location: "333.1351", 18 | }); 19 | 20 | return res?.data?.list || []; 21 | }); 22 | 23 | const renderMediaItem = (item: any) => ( 24 | {item.author} 36 | } 37 | onPress={() => 38 | play({ 39 | type: "mv", 40 | bvid: item.bvid, 41 | title: item.music_title, 42 | }) 43 | } 44 | /> 45 | ); 46 | 47 | return ( 48 | 49 |

热歌精选

50 | {displayMode === "card" ? ( 51 | 59 | ) : ( 60 |
{data?.map(renderMediaItem)}
61 | )} 62 |
63 | ); 64 | }; 65 | 66 | export default MusicRank; 67 | -------------------------------------------------------------------------------- /src/components/menu/menu-item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLocation, useParams } from "react-router"; 3 | 4 | import { Button, Link as HeroLink, Image } from "@heroui/react"; 5 | import clx from "classnames"; 6 | 7 | export interface MenuItemProps { 8 | /** 菜单项标签 */ 9 | title: string; 10 | /** 菜单项链接 */ 11 | href: string; 12 | /** 菜单项图标 */ 13 | icon?: React.ComponentType<{ size?: number | string }>; 14 | /** 封面 */ 15 | cover?: string; 16 | /** 激活状态图标 */ 17 | activeIcon?: React.ComponentType<{ size?: number | string }>; 18 | className?: string; 19 | onPress?: VoidFunction; 20 | } 21 | 22 | const MenuItem: React.FC = ({ 23 | title, 24 | href, 25 | cover, 26 | icon: Icon, 27 | activeIcon: ActiveIcon, 28 | className, 29 | onPress, 30 | }) => { 31 | const location = useLocation(); 32 | const { id } = useParams(); 33 | 34 | const isActive = location.pathname === href || (id && href.split("?")[0].includes(id)); 35 | 36 | return ( 37 | 70 | ); 71 | }; 72 | 73 | export default MenuItem; 74 | -------------------------------------------------------------------------------- /src/service/audio-web-url.ts: -------------------------------------------------------------------------------- 1 | import { apiRequest } from "./request"; 2 | 3 | /** 4 | * 获取音频流URL(可获取付费音频) 5 | * GET https://api.bilibili.com/audio/music-service-c/url 6 | * 认证:APP(access_key)或 Cookie(SESSDATA) 7 | */ 8 | export interface AudioStreamUrlRequestParams { 9 | /** APP 登录 Token(APP 方式必要) */ 10 | access_key?: string; 11 | /** 音频 auid(必要) */ 12 | songid: number | string; 13 | /** 音质代码(必要:0=128K,1=192K,2=320K,3=FLAC) */ 14 | quality: number; 15 | /** 必须为 2(必要) */ 16 | privilege?: number; 17 | /** 当前用户 mid(必要,可为任意值) */ 18 | mid: number; 19 | /** 平台标识(必要,可为任意值,如 'pc' 或 'android') */ 20 | platform: string; 21 | } 22 | 23 | /** 24 | * 可用音质项 25 | */ 26 | export interface AudioStreamQuality { 27 | /** 音质代码 */ 28 | type: number; 29 | /** 音质名称 */ 30 | desc: string; 31 | /** 该音质的文件大小(字节) */ 32 | size: number; 33 | /** 比特率标签 */ 34 | bps: string; 35 | /** 音质标签 */ 36 | tag: string; 37 | /** 是否需要会员权限:0 不需要;1 需要 */ 38 | require: number; 39 | /** 会员权限标签 */ 40 | requiredesc: string; 41 | } 42 | 43 | /** 44 | * 音频流URL(可获取付费音频)- 数据本体 45 | */ 46 | export interface AudioStreamUrlData { 47 | /** 音频 auid */ 48 | sid: number; 49 | /** 音质标识:-1 试听片段;0 128K;1 192K;2 320K;3 FLAC */ 50 | type: number; 51 | /** 作用尚不明确,通常为空字符串 */ 52 | info: string; 53 | /** 有效时长(秒),一般为 3 小时 */ 54 | timeout: number; 55 | /** 文件大小(字节);当 type 为 -1 时为 0 */ 56 | size: number; 57 | /** 音频流 URL 列表(主/备) */ 58 | cdns: string[]; 59 | /** 音质列表 */ 60 | qualities: AudioStreamQuality[]; 61 | /** 音频标题 */ 62 | title: string; 63 | /** 音频封面 url */ 64 | cover: string; 65 | } 66 | 67 | /** 68 | * 音频流URL(可获取付费音频)- 顶层响应 69 | */ 70 | export interface AudioStreamUrlResponse { 71 | /** 返回值:0 成功;7201006 未找到或下架;72000000 请求错误 */ 72 | code: number; 73 | /** 错误信息,默认为 "success" */ 74 | msg: string; 75 | /** 数据本体 */ 76 | data: AudioStreamUrlData; 77 | } 78 | 79 | /** 80 | * 获取音频流URL(可获取付费音频) 81 | * @param params 请求参数 82 | */ 83 | export const getAudioWebStreamUrl = (params: AudioStreamUrlRequestParams) => { 84 | return apiRequest.get("/audio/music-service-c/url", { params }); 85 | }; 86 | -------------------------------------------------------------------------------- /src/common/utils/color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert hex color to HSL 3 | */ 4 | export function hexToHsl(hex: string) { 5 | // Convert hex to RGB first 6 | const [r, g, b] = hexToRgb(hex); 7 | 8 | // Normalize RGB values 9 | const normalizedR = r / 255; 10 | const normalizedG = g / 255; 11 | const normalizedB = b / 255; 12 | 13 | // Find the maximum and minimum values of R, G, B 14 | const max = Math.max(normalizedR, normalizedG, normalizedB); 15 | const min = Math.min(normalizedR, normalizedG, normalizedB); 16 | 17 | // Calculate the lightness 18 | const lightness = (max + min) / 2; 19 | 20 | // If the maximum and minimum are equal, there is no saturation 21 | if (max === min) { 22 | return `${0} ${0}% ${lightness * 100}%`; 23 | } 24 | 25 | // Calculate the saturation 26 | let saturation = 0; 27 | 28 | if (lightness < 0.5) { 29 | saturation = (max - min) / (max + min); 30 | } else { 31 | saturation = (max - min) / (2 - max - min); 32 | } 33 | 34 | // Calculate the hue 35 | let hue; 36 | 37 | if (max === normalizedR) { 38 | hue = (normalizedG - normalizedB) / (max - min); 39 | } else if (max === normalizedG) { 40 | hue = 2 + (normalizedB - normalizedR) / (max - min); 41 | } else { 42 | hue = 4 + (normalizedR - normalizedG) / (max - min); 43 | } 44 | 45 | hue *= 60; 46 | if (hue < 0) hue += 360; 47 | 48 | return `${hue.toFixed(2)} ${(saturation * 100).toFixed(2)}% ${(lightness * 100).toFixed(2)}%`; 49 | } 50 | 51 | /** 52 | * Convert hex color to RGB 53 | */ 54 | function hexToRgb(hex: string): number[] { 55 | // Convert hex to RGB first 56 | let r = 0, 57 | g = 0, 58 | b = 0; 59 | 60 | if (hex.length === 4 || hex.length === 5) { 61 | r = parseInt(hex[1] + hex[1], 16); 62 | g = parseInt(hex[2] + hex[2], 16); 63 | b = parseInt(hex[3] + hex[3], 16); 64 | } else if (hex.length === 7 || hex.length === 9) { 65 | r = parseInt(hex.slice(1, 3), 16); 66 | g = parseInt(hex.slice(3, 5), 16); 67 | b = parseInt(hex.slice(5, 7), 16); 68 | } else { 69 | throw new Error("Invalid hex color format"); 70 | } 71 | 72 | return [r, g, b]; 73 | } 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript cache 46 | *.tsbuildinfo 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Microbundle cache 55 | .rpt2_cache/ 56 | .rts2_cache_cjs/ 57 | .rts2_cache_es/ 58 | .rts2_cache_umd/ 59 | 60 | # Optional REPL history 61 | .node_repl_history 62 | 63 | # Output of 'npm pack' 64 | *.tgz 65 | 66 | # Yarn Integrity file 67 | .yarn-integrity 68 | 69 | # dotenv environment variables file 70 | .env 71 | .env.test 72 | 73 | # parcel-bundler cache (https://parceljs.org/) 74 | .cache 75 | 76 | # Next.js build output 77 | .next 78 | 79 | # Nuxt.js build / generate output 80 | .nuxt 81 | dist 82 | 83 | # Gatsby files 84 | .cache/ 85 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 86 | # https://nextjs.org/blog/next-9-1#public-directory-support 87 | # public 88 | 89 | # vuepress build output 90 | .vuepress/dist 91 | 92 | # Serverless directories 93 | .serverless/ 94 | 95 | # FuseBox cache 96 | .fusebox/ 97 | 98 | # DynamoDB Local files 99 | .dynamodb/ 100 | 101 | # TernJS port file 102 | .tern-port 103 | 104 | # electron-builder cache 105 | electron-builder 106 | api.example.json 107 | .electron 108 | 109 | .trae 110 | -------------------------------------------------------------------------------- /src/pages/follow-list/user-card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useNavigate } from "react-router"; 3 | 4 | import { Avatar, Card, CardBody, addToast } from "@heroui/react"; 5 | import { RiDislikeLine } from "@remixicon/react"; 6 | 7 | import type { RelationListItem } from "@/service/relation-followings"; 8 | 9 | import AsyncButton from "@/components/async-button"; 10 | import { postRelationModify, UserRelationAction } from "@/service/relation-modify"; 11 | 12 | interface Props { 13 | u: RelationListItem; 14 | refresh: () => void; 15 | } 16 | 17 | const UserCard = ({ u, refresh }: Props) => { 18 | const navigate = useNavigate(); 19 | 20 | const handleUnfollow = async () => { 21 | const res = await postRelationModify({ fid: u.mid, act: UserRelationAction.Unfollow }); 22 | if (res?.code !== 0) { 23 | addToast({ 24 | title: "取消关注失败", 25 | color: "danger", 26 | }); 27 | return; 28 | } 29 | 30 | refresh(); 31 | }; 32 | 33 | return ( 34 | navigate(`/user/${u.mid}`)} 41 | className="group relative h-full" 42 | > 43 | 44 | 45 |
46 | {u.uname} 47 | {u.sign} 48 |
49 |
50 | 61 | 62 | 63 |
64 | ); 65 | }; 66 | 67 | export default UserCard; 68 | -------------------------------------------------------------------------------- /electron/mini-player.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "electron"; 2 | import isDev from "electron-is-dev"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | let miniPlayer: BrowserWindow | null = null; 10 | 11 | const createMiniPlayer = () => { 12 | miniPlayer = new BrowserWindow({ 13 | title: "Biu Mini Player", 14 | show: true, 15 | hasShadow: true, 16 | width: 320, 17 | height: 100, 18 | resizable: false, 19 | roundedCorners: true, 20 | center: true, 21 | // 隐藏窗口标题栏和窗口按钮 22 | frame: false, 23 | transparent: true, 24 | titleBarOverlay: false, 25 | alwaysOnTop: true, 26 | skipTaskbar: true, 27 | webPreferences: { 28 | preload: path.join(__dirname, "preload.cjs"), 29 | webSecurity: true, 30 | contextIsolation: true, 31 | nodeIntegration: false, 32 | devTools: isDev, 33 | }, 34 | }); 35 | 36 | miniPlayer.webContents.setWindowOpenHandler(() => { 37 | return { action: "deny" }; 38 | }); 39 | 40 | miniPlayer.webContents.on("before-input-event", (event, input) => { 41 | if ((input.control || input.meta) && input.key.toLowerCase() === "r") { 42 | event.preventDefault(); 43 | } 44 | }); 45 | 46 | miniPlayer.webContents.on("context-menu", e => { 47 | e.preventDefault(); 48 | }); 49 | 50 | if (process.platform === "win32") { 51 | // 拦截 WM_INITMENU (0x0116) 消息,阻止系统菜单 52 | miniPlayer.hookWindowMessage(0x0116, () => { 53 | miniPlayer?.setEnabled(false); 54 | setTimeout(() => { 55 | miniPlayer?.setEnabled(true); 56 | }, 100); 57 | return true; 58 | }); 59 | } 60 | 61 | const indexPath = path.resolve(__dirname, "../dist/web/index.html"); 62 | miniPlayer.loadFile(indexPath, { hash: "mini-player" }); 63 | }; 64 | 65 | const destroyMiniPlayer = () => { 66 | if (miniPlayer) { 67 | if (!miniPlayer.isDestroyed()) { 68 | miniPlayer.destroy(); 69 | } 70 | miniPlayer = null; 71 | } 72 | }; 73 | 74 | export { miniPlayer, createMiniPlayer, destroyMiniPlayer }; 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | name: Build on ${{ matrix.os }} 16 | runs-on: ${{ matrix.os }} 17 | env: 18 | HUSKY: 0 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [macos-latest, windows-latest, ubuntu-latest] 23 | permissions: 24 | contents: write 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Install pnpm 33 | uses: pnpm/action-setup@v4 34 | 35 | - name: Use Node.js lts 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version-file: ".nvmrc" 39 | cache: "pnpm" 40 | 41 | - name: Configure Git to use HTTPS instead of SSH 42 | run: git config --global url."https://github.com/".insteadOf "git@github.com:" 43 | 44 | - name: Install dependencies 45 | run: pnpm install --frozen-lockfile 46 | 47 | - name: Build & Publish 48 | env: 49 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | run: pnpm build 51 | 52 | publish-release: 53 | name: Publish Release 54 | runs-on: ubuntu-latest 55 | needs: build 56 | if: startsWith(github.ref, 'refs/tags/v') 57 | permissions: 58 | contents: write 59 | steps: 60 | - name: Publish release (remove draft) 61 | env: 62 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | run: gh release edit "${{ github.ref_name }}" --draft=false --repo "${{ github.repository }}" 64 | - name: Mark as stable 65 | env: 66 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | run: gh release edit "${{ github.ref_name }}" --prerelease=false --repo "${{ github.repository }}" 68 | - name: Mark as latest 69 | env: 70 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | run: gh release edit "${{ github.ref_name }}" --latest --repo "${{ github.repository }}" 72 | -------------------------------------------------------------------------------- /src/service/space-top-arc.ts: -------------------------------------------------------------------------------- 1 | import type { Rights, Owner, Stat, Dimension } from "./web-interface-view"; 2 | 3 | import { apiRequest } from "./request"; 4 | 5 | /** 6 | * 查询用户置顶视频 7 | */ 8 | export interface SpaceTopArcRequestParams { 9 | /** 目标用户 mid */ 10 | vmid: number; 11 | } 12 | 13 | export interface SpaceTopArcData { 14 | aid: number; 15 | videos: number; 16 | tid: number; 17 | tname: string; 18 | copyright: number; 19 | pic: string; 20 | title: string; 21 | pubdate: number; 22 | ctime: number; 23 | desc: string; 24 | state: number; 25 | attribute: number; 26 | duration: number; 27 | rights: Rights; 28 | owner: Owner; 29 | stat: Stat; 30 | dynamic: string; 31 | cid: number; 32 | dimension: Dimension; 33 | bvid: string; 34 | reason: string; 35 | inter_video: boolean; 36 | } 37 | 38 | export interface SpaceTopArcResponse { 39 | code: number; 40 | message: string; 41 | ttl: number; 42 | data: SpaceTopArcData | null; 43 | } 44 | 45 | export const getSpaceTopArc = (params: SpaceTopArcRequestParams): Promise => { 46 | return apiRequest.get("/x/space/top/arc", { 47 | params, 48 | }); 49 | }; 50 | 51 | /** 52 | * 设置置顶视频 53 | * avid 与 bvid 二选一,reason 可选 54 | */ 55 | export interface SpaceTopArcSetBody { 56 | aid?: number; 57 | bvid?: string; 58 | reason?: string; 59 | } 60 | 61 | export interface SpaceTopArcSetResponse { 62 | code: number; 63 | message: string; 64 | ttl: number; 65 | } 66 | 67 | export const setSpaceTopArc = (data: SpaceTopArcSetBody): Promise => { 68 | return apiRequest.post("/x/space/top/arc/set", data, { 69 | useFormData: true, 70 | useCSRF: true, 71 | }); 72 | }; 73 | 74 | /** 75 | * 取消置顶视频 76 | */ 77 | export interface SpaceTopArcCancelResponse { 78 | code: number; 79 | message: string; 80 | ttl: number; 81 | } 82 | 83 | export const cancelSpaceTopArc = (): Promise => { 84 | return apiRequest.post( 85 | "/x/space/top/arc/cancel", 86 | {}, 87 | { 88 | useFormData: true, 89 | useCSRF: true, 90 | }, 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /electron/network/interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { OnBeforeSendHeadersListenerDetails, OnHeadersReceivedListenerDetails } from "electron"; 2 | 3 | import httpCookie from "cookie"; 4 | import { session } from "electron"; 5 | 6 | import { UserAgent } from "./user-agent"; 7 | 8 | export function installWebRequestInterceptors() { 9 | const urls = ["http://*/*", "https://*/*"], 10 | origin = "https://www.bilibili.com", 11 | referer = "https://www.bilibili.com"; 12 | 13 | const onBeforeSendHeadersHandler = async ( 14 | details: OnBeforeSendHeadersListenerDetails, 15 | callback: (response: { requestHeaders?: Record }) => void, 16 | ) => { 17 | const headers = details.requestHeaders || {}; 18 | 19 | headers["Referer"] = referer; 20 | headers["Origin"] = origin; // 与响应注入的 Allow-Origin 保持一致 21 | headers["User-Agent"] = UserAgent; 22 | 23 | callback({ requestHeaders: headers }); 24 | }; 25 | 26 | // 新增:响应头拦截,重写 Set-Cookie 的 SameSite 与 Secure 27 | const onHeadersReceivedHandler = ( 28 | details: OnHeadersReceivedListenerDetails, 29 | callback: (response: { responseHeaders?: Record }) => void, 30 | ) => { 31 | const responseHeaders = details.responseHeaders || {}; 32 | const setCookieKey = Object.keys(responseHeaders).find(k => k.toLowerCase() === "set-cookie"); 33 | 34 | if (!setCookieKey) { 35 | callback({ responseHeaders }); 36 | return; 37 | } 38 | 39 | const raw = responseHeaders[setCookieKey.toLowerCase()]; 40 | const cookies = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []; 41 | 42 | const rewritten = cookies.map(cookie => { 43 | const setCookieObject = httpCookie.parseSetCookie(cookie); 44 | setCookieObject.sameSite = "none"; 45 | setCookieObject.secure = true; 46 | 47 | return httpCookie.stringifySetCookie(setCookieObject); 48 | }); 49 | 50 | responseHeaders[setCookieKey] = rewritten; 51 | callback({ responseHeaders }); 52 | }; 53 | 54 | session.defaultSession.webRequest.onBeforeSendHeaders({ urls }, onBeforeSendHeadersHandler); 55 | session.defaultSession.webRequest.onHeadersReceived({ urls }, onHeadersReceivedHandler); 56 | } 57 | -------------------------------------------------------------------------------- /src/routes.tsx: -------------------------------------------------------------------------------- 1 | import type { RouteObject } from "react-router"; 2 | 3 | import Layout from "./layout"; 4 | import ArtistRank from "./pages/artist-rank"; 5 | import DownloadList from "./pages/download-list"; 6 | import EmptyPage from "./pages/empty"; 7 | import FollowList from "./pages/follow-list"; 8 | import History from "./pages/history"; 9 | import Later from "./pages/later"; 10 | import MiniPlayer from "./pages/mini-player"; 11 | import MusicRank from "./pages/music-rank"; 12 | import MusicRecommend from "./pages/music-recommend"; 13 | import NotFound from "./pages/not-found"; 14 | import Search from "./pages/search"; 15 | import Settings from "./pages/settings"; 16 | import UserProfile from "./pages/user-profile"; 17 | import Folder from "./pages/video-collection"; 18 | 19 | const routes: RouteObject[] = [ 20 | { 21 | path: "/", 22 | element: , 23 | children: [ 24 | { 25 | index: true, 26 | element: , 27 | }, 28 | { 29 | path: "artist-rank", 30 | element: , 31 | }, 32 | { 33 | path: "music-recommend", 34 | element: , 35 | }, 36 | { 37 | path: "later", 38 | element: , 39 | }, 40 | { 41 | path: "history", 42 | element: , 43 | }, 44 | { 45 | path: "follow", 46 | element: , 47 | }, 48 | { 49 | path: "collection/:id", 50 | element: , 51 | }, 52 | { 53 | path: "user/:id", 54 | element: , 55 | }, 56 | { 57 | path: "settings", 58 | element: , 59 | }, 60 | { 61 | path: "download-list", 62 | element: , 63 | }, 64 | { 65 | path: "search", 66 | element: , 67 | }, 68 | { 69 | path: "empty", 70 | element: , 71 | }, 72 | ], 73 | }, 74 | { 75 | path: "mini-player", 76 | element: , 77 | }, 78 | { 79 | path: "*", 80 | element: , 81 | }, 82 | ]; 83 | 84 | export default routes; 85 | -------------------------------------------------------------------------------- /src/types/geetest.d.ts: -------------------------------------------------------------------------------- 1 | type GeetestProduct = "float" | "popup" | "custom" | "bind"; 2 | 3 | type GeetestLanguage = "zh-cn"; 4 | 5 | interface GeetestInitOptionsBase { 6 | gt: string; 7 | challenge: string; 8 | offline: boolean; 9 | new_captcha: boolean; 10 | product?: GeetestProduct; 11 | width?: string; 12 | lang?: GeetestLanguage; 13 | https?: boolean; 14 | timeout?: number; 15 | remUnit?: number; 16 | zoomEle?: string | Element; 17 | hideSuccess?: boolean; 18 | hideClose?: boolean; 19 | hideRefresh?: boolean; 20 | api_server?: string; 21 | api_server_v3?: string[]; 22 | } 23 | 24 | interface GeetestInitOptionsPopupOrBind extends GeetestInitOptionsBase { 25 | product?: "popup" | "bind"; 26 | area?: string | Element; 27 | next_width?: string; 28 | bg_color?: string; 29 | } 30 | 31 | interface GeetestInitOptionsFloat extends GeetestInitOptionsBase { 32 | product?: "float"; 33 | next_width?: string; 34 | } 35 | 36 | interface GeetestInitOptionsCustom extends GeetestInitOptionsBase { 37 | product: "custom"; 38 | area: string | Element; 39 | next_width?: string; 40 | bg_color?: string; 41 | } 42 | 43 | type GeetestInitOptions = 44 | | GeetestInitOptionsCustom 45 | | GeetestInitOptionsPopupOrBind 46 | | GeetestInitOptionsFloat 47 | | GeetestInitOptionsBase; 48 | 49 | interface GeetestValidate { 50 | challenge?: string; 51 | validate?: string; 52 | seccode?: string; 53 | geetest_challenge?: string; 54 | geetest_validate?: string; 55 | geetest_seccode?: string; 56 | } 57 | 58 | interface GeetestCaptcha { 59 | appendTo(target: string | Element): GeetestCaptcha; 60 | bindForm(target: string | Element): GeetestCaptcha; 61 | onReady(cb: () => void): GeetestCaptcha; 62 | onSuccess(cb: () => void): GeetestCaptcha; 63 | onError(cb: (error?: any) => void): GeetestCaptcha; 64 | verify(): void; 65 | getValidate(): GeetestValidate | null | false; 66 | reset?(): void; 67 | destroy?(): void; 68 | } 69 | 70 | type InitGeetestCallback = (captchaObj: GeetestCaptcha) => void; 71 | 72 | declare global { 73 | interface Window { 74 | initGeetest(config: GeetestInitOptions, callback: InitGeetestCallback): void; 75 | } 76 | } 77 | 78 | export {}; 79 | -------------------------------------------------------------------------------- /src/pages/search/video-list.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Link } from "@heroui/react"; 4 | 5 | import type { SearchVideoItem } from "@/service/web-interface-search-type"; 6 | 7 | import { formatUrlProtocal } from "@/common/utils/url"; 8 | import Empty from "@/components/empty"; 9 | import GridList from "@/components/grid-list"; 10 | import MediaItem from "@/components/media-item"; 11 | import { usePlayList } from "@/store/play-list"; 12 | import { useSettings } from "@/store/settings"; 13 | 14 | export type SearchVideoProps = { 15 | items: SearchVideoItem[]; 16 | }; 17 | 18 | export default function SearchVideo({ items }: SearchVideoProps) { 19 | const play = usePlayList(s => s.play); 20 | const displayMode = useSettings(state => state.displayMode); 21 | 22 | if (!items?.length) return ; 23 | 24 | const renderMediaItem = (item: SearchVideoItem) => ( 25 | 40 | 41 | {item.author} 42 | 43 | {item.duration} 44 | 45 | ) 46 | } 47 | onPress={() => 48 | play({ 49 | type: "mv", 50 | bvid: item.bvid, 51 | title: item.title, 52 | cover: formatUrlProtocal(item.pic), 53 | ownerName: item.author, 54 | ownerMid: item.mid, 55 | }) 56 | } 57 | /> 58 | ); 59 | 60 | return displayMode === "card" ? ( 61 | 62 | ) : ( 63 |
{items?.map(renderMediaItem)}
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /electron/ipc/channel.ts: -------------------------------------------------------------------------------- 1 | export const channel = { 2 | store: { 3 | getSettings: "settings:get", 4 | setSettings: "settings:set", 5 | clearSettings: "settings:clear", 6 | get: "store:get", 7 | set: "store:set", 8 | clear: "store:clear", 9 | }, 10 | dialog: { 11 | selectDirectory: "dialog:select-directory", 12 | selectFile: "dialog:select-file", 13 | openDirectory: "dialog:open-directory", 14 | openExternal: "dialog:open-external", 15 | }, 16 | font: { 17 | getFonts: "font:get-fonts", 18 | }, 19 | file: { 20 | getSize: "file:get-size", 21 | }, 22 | download: { 23 | getList: "download:get-list", 24 | getDownloadData: "download:get-download-data", 25 | add: "download:add", 26 | addList: "download:add-list", 27 | pause: "download:pause", 28 | resume: "download:resume", 29 | cancel: "download:cancel", 30 | retry: "download:retry", 31 | sync: "download:sync", 32 | clear: "download:clear", 33 | }, 34 | router: { 35 | navigate: "router:navigate", 36 | }, 37 | http: { 38 | get: "http:get", 39 | post: "http:post", 40 | }, 41 | player: { 42 | state: "player:state", 43 | prev: "player:prev", 44 | next: "player:next", 45 | toggle: "player:toggle", 46 | }, 47 | app: { 48 | getVersion: "app:get-version", 49 | checkUpdate: "app:check-update", 50 | onUpdateAvailable: "app:on-update-available", 51 | downloadUpdate: "app:download-update", 52 | updateMessage: "app:update-message", 53 | quitAndInstall: "app:quit-and-install", 54 | openInstallerDirectory: "app:open-installer-directory", 55 | onBeforeQuit: "app:on-before-quit", 56 | }, 57 | cookie: { 58 | get: "cookie:get", 59 | }, 60 | window: { 61 | switchToMini: "window:switch-to-mini", 62 | switchToMain: "window:switch-to-main", 63 | minimize: "window:minimize", 64 | toggleMaximize: "window:toggle-maximize", 65 | close: "window:close", 66 | maximize: "window:maximize", 67 | unmaximize: "window:unmaximize", 68 | isMaximized: "window:is-maximized", 69 | enterFullScreen: "window:enter-full-screen", 70 | leaveFullScreen: "window:leave-full-screen", 71 | isFullScreen: "window:is-full-screen", 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/layout/playbar/right/rate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import { Button, Tooltip } from "@heroui/react"; 4 | 5 | import { usePlayList } from "@/store/play-list"; 6 | 7 | import { PlayRate } from "../constants"; 8 | 9 | const Rate = () => { 10 | const rate = usePlayList(s => s.rate); 11 | const setRate = usePlayList(s => s.setRate); 12 | const [isTooltipOpen, setIsTooltipOpen] = useState(false); 13 | 14 | const tooltipId = "rate-tooltip"; 15 | 16 | // 处理点击事件,切换tooltip显示状态 17 | const handleClick = () => { 18 | setIsTooltipOpen(!isTooltipOpen); 19 | }; 20 | 21 | // 处理鼠标进入事件,打开tooltip 22 | const handleMouseEnter = () => { 23 | setIsTooltipOpen(true); 24 | }; 25 | 26 | // 处理鼠标离开事件,关闭tooltip 27 | const handleMouseLeave = () => { 28 | setIsTooltipOpen(false); 29 | }; 30 | 31 | return ( 32 | setIsTooltipOpen(true)} 44 | onMouseLeave={() => setIsTooltipOpen(false)} 45 | > 46 | {PlayRate.map(v => ( 47 | 59 | ))} 60 | 61 | } 62 | > 63 | 76 | 77 | ); 78 | }; 79 | 80 | export default Rate; 81 | -------------------------------------------------------------------------------- /src/common/constants/video.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Bilibili 视频清晰度(qn) 3 | * 获取 720P 及以上清晰度视频时需要登录(Cookie) 4 | */ 5 | export enum VideoQuality { 6 | /** 7 | * 240P 极速 8 | * 备注:仅 MP4 方式支持 9 | */ 10 | Q240P = 6, 11 | 12 | /** 13 | * 360P 流畅 14 | */ 15 | Q360P = 16, 16 | 17 | /** 18 | * 480P 清晰 19 | */ 20 | Q480P = 32, 21 | 22 | /** 23 | * 720P 高清 24 | * 备注:WEB 端默认值;前端需登录才能选择,但直接发起请求可不登录获取 720P 取流;无 720P 时则为 720P60 25 | */ 26 | Q720P = 64, 27 | 28 | /** 29 | * 720P60 高帧率 30 | * 备注:需要认证登录账号 31 | */ 32 | Q720P60 = 74, 33 | 34 | /** 35 | * 1080P 高清 36 | * 备注:WEB 端与 APP 端默认值;需要认证登录账号 37 | */ 38 | Q1080P = 80, 39 | 40 | /** 41 | * 智能修复(AI 画质增强) 42 | * 备注:仅支持 DASH 格式;需要 fnval & 12240 = 12240;需要认证登录账号 43 | */ 44 | SmartEnhance = 100, 45 | 46 | /** 47 | * 1080P+ 高码率 48 | * 备注:大多情况需要大会员认证 49 | */ 50 | Q1080PPlus = 112, 51 | 52 | /** 53 | * 1080P60 高帧率 54 | * 备注:大多情况需要大会员认证 55 | */ 56 | Q1080P60 = 116, 57 | 58 | /** 59 | * 4K 超清 60 | * 备注:需要 fnval & 128 = 128 且 fourk = 1;大多情况需要大会员认证 61 | */ 62 | Q4K = 120, 63 | 64 | /** 65 | * HDR 真彩色 66 | * 备注:仅支持 DASH 格式;需要 fnval & 64 = 64;大多情况需要大会员认证 67 | */ 68 | HDR = 125, 69 | 70 | /** 71 | * 杜比视界(Dolby Vision) 72 | * 备注:仅支持 DASH 格式;需要 fnval & 512 = 512;大多情况需要大会员认证 73 | */ 74 | DolbyVision = 126, 75 | 76 | /** 77 | * 8K 超高清 78 | * 备注:仅支持 DASH 格式;需要 fnval & 1024 = 1024;大多情况需要大会员认证 79 | */ 80 | Q8K = 127, 81 | } 82 | 83 | /** 获取dash流视频请求参数 */ 84 | export enum VideoFnval { 85 | /** 86 | * MP4 格式,仅 H.264 编码 87 | */ 88 | MP4 = 1, 89 | /** 90 | * DASH 格式 91 | */ 92 | Dash = 16, 93 | /** 94 | * HDR 视频, 仅 H.265 编码需要qn=125, 大会员认证 95 | */ 96 | HDR = 64, 97 | /** 98 | * 4K 分辨率, 与fourk字段协同作用,需要qn=120,大会员认证 99 | */ 100 | FourK = 128, 101 | /** 102 | * 杜比音频,大会员认证 103 | */ 104 | DolbyAudio = 256, 105 | /** 106 | * 杜比视界, 大会员认证 107 | */ 108 | DolbyVideo = 512, 109 | /** 110 | * 8K 分辨率,需要qn=127,大会员认证 111 | */ 112 | EightK = 1024, 113 | /** 114 | * AV1 编码 115 | */ 116 | AV1 = 2048, 117 | /** 118 | * 所有可用 DASH 视频流 119 | */ 120 | AllDash = 4048, 121 | } 122 | -------------------------------------------------------------------------------- /src/layout/playbar/center/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Button } from "@heroui/react"; 4 | import { RiPauseCircleFill, RiPlayCircleFill, RiSkipBackFill, RiSkipForwardFill } from "@remixicon/react"; 5 | 6 | import { usePlayList } from "@/store/play-list"; 7 | 8 | import { PlayBarIconSize } from "../constants"; 9 | import Progress from "./progress"; 10 | 11 | const Control = () => { 12 | const prev = usePlayList(state => state.prev); 13 | const next = usePlayList(state => state.next); 14 | const list = usePlayList(state => state.list); 15 | const togglePlay = usePlayList(state => state.togglePlay); 16 | const isPlaying = usePlayList(state => state.isPlaying); 17 | 18 | const isEmptyPlayList = list.length === 0; 19 | const isSingle = list.length === 1; 20 | 21 | return ( 22 |
23 |
24 | 35 | 49 | 60 |
61 | 62 |
63 | ); 64 | }; 65 | 66 | export default Control; 67 | -------------------------------------------------------------------------------- /src/pages/user-profile/favorites.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from "react-router"; 2 | 3 | import { Pagination } from "@heroui/react"; 4 | import { usePagination } from "ahooks"; 5 | 6 | import { CollectionType } from "@/common/constants/collection"; 7 | import { formatSecondsToDate } from "@/common/utils"; 8 | import GridList from "@/components/grid-list"; 9 | import ImageCard from "@/components/image-card"; 10 | import { getFavFolderCreatedList } from "@/service/fav-folder-created-list"; 11 | 12 | /** 收藏夹 */ 13 | const Favorites = () => { 14 | const { id } = useParams(); 15 | const navigate = useNavigate(); 16 | 17 | const { 18 | data, 19 | pagination, 20 | loading, 21 | runAsync: getPageData, 22 | } = usePagination( 23 | async ({ current, pageSize }) => { 24 | const res = await getFavFolderCreatedList({ 25 | up_mid: Number(id ?? ""), 26 | ps: pageSize, 27 | pn: current, 28 | }); 29 | 30 | return { 31 | total: res?.data?.count ?? 0, 32 | list: res?.data?.list ?? [], 33 | }; 34 | }, 35 | { 36 | ready: Boolean(id), 37 | refreshDeps: [id], 38 | defaultPageSize: 20, 39 | }, 40 | ); 41 | 42 | return ( 43 | <> 44 | ( 49 | 54 | {formatSecondsToDate(item.ctime)} 55 | {item.media_count}个视频 56 | 57 | } 58 | onPress={() => navigate(`/collection/${item.id}?type=${CollectionType.Favorite}`)} 59 | /> 60 | )} 61 | /> 62 | {pagination.totalPage > 1 && ( 63 |
64 | getPageData({ current: next, pageSize: 20 })} 69 | /> 70 |
71 | )} 72 | 73 | ); 74 | }; 75 | 76 | export default Favorites; 77 | --------------------------------------------------------------------------------