├── .editorconfig ├── .erb ├── configs │ ├── .eslintrc │ ├── webpack.config.base.ts │ ├── webpack.config.eslint.ts │ ├── webpack.config.main.prod.ts │ ├── webpack.config.renderer.dev.dll.ts │ ├── webpack.config.renderer.dev.ts │ ├── webpack.config.renderer.prod.ts │ └── webpack.paths.ts ├── img │ ├── erb-banner.svg │ └── erb-logo.png ├── mocks │ └── fileMock.js └── scripts │ ├── .eslintrc │ ├── check-build-exists.ts │ ├── check-native-dep.js │ ├── check-node-env.js │ ├── check-port-in-use.js │ ├── clean.js │ ├── delete-source-maps.js │ ├── electron-rebuild.js │ ├── link-modules.ts │ └── notarize.js ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .husky └── pre-commit ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_DEV.md ├── assets ├── assets.d.ts ├── entitlements.mac.plist ├── fonts │ └── lolita.ttf ├── icon.icns ├── icon.ico ├── icon.png ├── icon.svg └── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 16x16.png │ ├── 24x24.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ ├── 64x64.png │ └── 96x96.png ├── extraResources └── fontlist │ ├── getSystemFonts.js │ ├── index.d.ts │ ├── index.js │ └── libs │ ├── darwin │ ├── fontlist │ ├── fontlist.m │ └── index.js │ ├── linux │ └── index.js │ └── win32 │ ├── fonts.vbs │ ├── getByPowerShell.js │ ├── getByVBS.js │ └── index.js ├── package-lock.json ├── package.json ├── release └── app │ ├── package-lock.json │ ├── package.json │ └── yarn.lock ├── screenshot └── session.png ├── src ├── __tests__ │ └── App.test.tsx ├── main │ ├── main.ts │ ├── menu.ts │ ├── preload.js │ ├── stateKeeper.ts │ └── util.ts └── renderer │ ├── App.css │ ├── App.tsx │ ├── api │ └── index.ts │ ├── app.global.scss │ ├── assets │ ├── css │ │ ├── main.scss │ │ ├── rc_dropdown.scss │ │ ├── rc_menu.scss │ │ ├── rc_notification.scss │ │ └── rc_toolTip.scss │ └── fonts │ │ └── lolita.ttf │ ├── bilive │ └── @types │ │ ├── LICENSE │ │ ├── danmaku.d.ts │ │ └── danmakuFormatted.d.ts │ ├── components │ ├── Base │ │ ├── DragSlider.tsx │ │ ├── Slider.tsx │ │ └── Switch.tsx │ └── Danmaku │ │ ├── DanmakuControl │ │ ├── CustomStyledPanel.tsx │ │ ├── DanmakuControl.tsx │ │ ├── LanguagePanel.tsx │ │ └── UserInfoConfigPanel.tsx │ │ ├── DanmakuGiftList │ │ └── DanmakuGiftList.tsx │ │ ├── DanmakuList │ │ └── DanmakuList.tsx │ │ ├── GiftBubble │ │ ├── Container.tsx │ │ ├── GiftBubble.tsx │ │ ├── GiftBubbleEntity.tsx │ │ ├── GiftBubbleItem.tsx │ │ └── Provider.tsx │ │ ├── LiveRoomLists │ │ └── LiveRoomLists.tsx │ │ ├── MsgEntity │ │ ├── MsgConnectSuccess.tsx │ │ ├── MsgConnecting.tsx │ │ ├── MsgDanmu.tsx │ │ ├── MsgDisconnected.tsx │ │ ├── MsgEntity.tsx │ │ ├── MsgGuardBuy.tsx │ │ ├── MsgGuardBuySystem.tsx │ │ ├── MsgInterActWord.tsx │ │ ├── MsgLive.tsx │ │ ├── MsgRoomBlock.tsx │ │ ├── MsgSendGift.tsx │ │ ├── MsgSuperChatCard.tsx │ │ ├── MsgUserAvatar.tsx │ │ ├── MsgVip.tsx │ │ ├── MsgWelcome.tsx │ │ └── MsgWelcomeGuard.tsx │ │ ├── MsgModel.ts │ │ ├── RankMessageLists │ │ └── RankMessageLists.tsx │ │ ├── SuperChatPanel │ │ ├── Container.tsx │ │ ├── Provider.tsx │ │ ├── SuperChatEntity.tsx │ │ ├── SuperChatItem.tsx │ │ └── SuperChatPanel.tsx │ │ ├── base │ │ └── Socket.ts │ │ ├── common │ │ ├── msg-struct.ts │ │ └── ws-url.ts │ │ └── index.tsx │ ├── config.ts │ ├── dao │ ├── ConfigDao.ts │ ├── LiveRoomDao.ts │ ├── StyledDao.ts │ ├── UesrInfoDao.ts │ └── UserAvatarDao.ts │ ├── i18n │ ├── index.ts │ └── locales │ │ ├── ja.json │ │ ├── lang.json │ │ └── zh-cn.json │ ├── index.ejs │ ├── index.tsx │ ├── reducers │ └── types.ts │ ├── store │ ├── features │ │ ├── configSlice.ts │ │ ├── counterSlice.ts │ │ └── danmakuSlice.ts │ ├── hooks.ts │ └── index.ts │ └── utils │ ├── .gitkeep │ ├── brotli.ts │ ├── common.ts │ ├── convert.ts │ ├── json-parser.ts │ ├── packet.ts │ ├── safeEval.ts │ ├── translation.ts │ ├── ttk.ts │ └── vioce.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.erb/configs/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "no-shadow": "off", 5 | "camelcase": "off", 6 | "global-require": "off", 7 | "import/no-dynamic-require": "off", 8 | "prefer-destructuring":"off", 9 | "no-use-before-define": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base webpack config used across other specific configs 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import webpackPaths from './webpack.paths'; 7 | import { dependencies as externals } from '../../release/app/package.json'; 8 | 9 | const configuration: webpack.Configuration = { 10 | externals: [...Object.keys(externals || {})], 11 | 12 | stats: 'errors-only', 13 | 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.[jt]sx?$/, 18 | exclude: /node_modules/, 19 | use: { 20 | loader: 'ts-loader', 21 | options: { 22 | // Remove this line to enable type checking in webpack builds 23 | transpileOnly: true, 24 | }, 25 | }, 26 | }, 27 | ], 28 | }, 29 | 30 | output: { 31 | path: webpackPaths.srcPath, 32 | // https://github.com/webpack/webpack/issues/1114 33 | library: { 34 | type: 'commonjs2', 35 | }, 36 | }, 37 | 38 | /** 39 | * Determine the array of extensions that should be used to resolve modules. 40 | */ 41 | resolve: { 42 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 43 | modules: [webpackPaths.srcPath, 'node_modules'], 44 | }, 45 | 46 | plugins: [ 47 | new webpack.EnvironmentPlugin({ 48 | NODE_ENV: 'production', 49 | }), 50 | ], 51 | }; 52 | 53 | export default configuration; 54 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.eslint.ts: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unresolved: off, import/no-self-import: off */ 2 | 3 | module.exports = require('./webpack.config.renderer.dev').default; 4 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.main.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack config for production electron main process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import { merge } from 'webpack-merge'; 8 | import TerserPlugin from 'terser-webpack-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | import checkNodeEnv from '../scripts/check-node-env'; 13 | import deleteSourceMaps from '../scripts/delete-source-maps'; 14 | 15 | checkNodeEnv('production'); 16 | deleteSourceMaps(); 17 | 18 | const devtoolsConfig = 19 | process.env.DEBUG_PROD === 'true' 20 | ? { 21 | devtool: 'source-map', 22 | } 23 | : {}; 24 | 25 | const configuration: webpack.Configuration = { 26 | ...devtoolsConfig, 27 | 28 | mode: 'production', 29 | 30 | target: 'electron-main', 31 | 32 | entry: { 33 | main: path.join(webpackPaths.srcMainPath, 'main.ts'), 34 | // preload: path.join(webpackPaths.srcMainPath, 'preload.js'), 35 | }, 36 | 37 | output: { 38 | path: webpackPaths.distMainPath, 39 | filename: '[name].js', 40 | }, 41 | 42 | optimization: { 43 | minimizer: [ 44 | new TerserPlugin({ 45 | parallel: true, 46 | }), 47 | ], 48 | }, 49 | 50 | plugins: [ 51 | new BundleAnalyzerPlugin({ 52 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 53 | }), 54 | 55 | /** 56 | * Create global constants which can be configured at compile time. 57 | * 58 | * Useful for allowing different behaviour between development builds and 59 | * release builds 60 | * 61 | * NODE_ENV should be production so that modules do not perform certain 62 | * development checks 63 | */ 64 | new webpack.EnvironmentPlugin({ 65 | NODE_ENV: 'production', 66 | DEBUG_PROD: false, 67 | START_MINIMIZED: false, 68 | }), 69 | ], 70 | 71 | /** 72 | * Disables webpack processing of __dirname and __filename. 73 | * If you run the bundle in node.js it falls back to these values of node.js. 74 | * https://github.com/webpack/webpack/issues/2010 75 | */ 76 | node: { 77 | __dirname: false, 78 | __filename: false, 79 | }, 80 | }; 81 | 82 | export default merge(baseConfig, configuration); 83 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.dll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Builds the DLL for development electron renderer process 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import path from 'path'; 7 | import { merge } from 'webpack-merge'; 8 | import baseConfig from './webpack.config.base'; 9 | import webpackPaths from './webpack.paths'; 10 | import { dependencies } from '../../package.json'; 11 | import checkNodeEnv from '../scripts/check-node-env'; 12 | 13 | checkNodeEnv('development'); 14 | 15 | const dist = webpackPaths.dllPath; 16 | 17 | const configuration: webpack.Configuration = { 18 | context: webpackPaths.rootPath, 19 | 20 | devtool: 'eval', 21 | 22 | mode: 'development', 23 | 24 | target: 'electron-renderer', 25 | 26 | externals: ['fsevents', 'crypto-browserify'], 27 | 28 | /** 29 | * Use `module` from `webpack.config.renderer.dev.js` 30 | */ 31 | module: require('./webpack.config.renderer.dev').default.module, 32 | 33 | entry: { 34 | renderer: Object.keys(dependencies || {}), 35 | }, 36 | 37 | output: { 38 | path: dist, 39 | filename: '[name].dev.dll.js', 40 | library: { 41 | name: 'renderer', 42 | type: 'var', 43 | }, 44 | }, 45 | 46 | plugins: [ 47 | new webpack.DllPlugin({ 48 | path: path.join(dist, '[name].json'), 49 | name: '[name]', 50 | }), 51 | 52 | /** 53 | * Create global constants which can be configured at compile time. 54 | * 55 | * Useful for allowing different behaviour between development builds and 56 | * release builds 57 | * 58 | * NODE_ENV should be production so that modules do not perform certain 59 | * development checks 60 | */ 61 | new webpack.EnvironmentPlugin({ 62 | NODE_ENV: 'development', 63 | }), 64 | 65 | new webpack.LoaderOptionsPlugin({ 66 | debug: true, 67 | options: { 68 | context: webpackPaths.srcPath, 69 | output: { 70 | path: webpackPaths.dllPath, 71 | }, 72 | }, 73 | }), 74 | ], 75 | }; 76 | 77 | export default merge(baseConfig, configuration); 78 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import webpack from 'webpack'; 4 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 5 | import chalk from 'chalk'; 6 | import { merge } from 'webpack-merge'; 7 | import { spawn, execSync } from 'child_process'; 8 | import baseConfig from './webpack.config.base'; 9 | import webpackPaths from './webpack.paths'; 10 | import checkNodeEnv from '../scripts/check-node-env'; 11 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 12 | 13 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 14 | // at the dev webpack config is not accidentally run in a production environment 15 | if (process.env.NODE_ENV === 'production') { 16 | checkNodeEnv('development'); 17 | } 18 | 19 | const port = process.env.PORT || 1212; 20 | const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json'); 21 | const requiredByDLLConfig = module.parent!.filename.includes( 22 | 'webpack.config.renderer.dev.dll' 23 | ); 24 | 25 | /** 26 | * Warn if the DLL is not built 27 | */ 28 | if ( 29 | !requiredByDLLConfig && 30 | !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) 31 | ) { 32 | console.log( 33 | chalk.black.bgYellow.bold( 34 | 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"' 35 | ) 36 | ); 37 | execSync('npm run postinstall'); 38 | } 39 | 40 | const configuration: webpack.Configuration = { 41 | devtool: 'inline-source-map', 42 | 43 | mode: 'development', 44 | 45 | target: 'electron-renderer', 46 | 47 | entry: [ 48 | `webpack-dev-server/client?http://localhost:${port}/dist`, 49 | 'webpack/hot/only-dev-server', 50 | path.join(webpackPaths.srcRendererPath, 'index.tsx'), 51 | ], 52 | 53 | output: { 54 | path: webpackPaths.distRendererPath, 55 | publicPath: '/', 56 | filename: 'renderer.dev.js', 57 | }, 58 | 59 | module: { 60 | rules: [ 61 | { 62 | test: /\.s?css$/, 63 | use: [ 64 | 'style-loader', 65 | { 66 | loader: 'css-loader', 67 | options: { 68 | modules: true, 69 | sourceMap: true, 70 | importLoaders: 1, 71 | }, 72 | }, 73 | 'sass-loader', 74 | ], 75 | include: /\.module\.s?(c|a)ss$/, 76 | }, 77 | { 78 | test: /\.s?css$/, 79 | use: ['style-loader', 'css-loader', 'sass-loader'], 80 | exclude: /\.module\.s?(c|a)ss$/, 81 | }, 82 | // Fonts 83 | { 84 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 85 | type: 'asset/resource', 86 | }, 87 | // Images 88 | { 89 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 90 | type: 'asset/resource', 91 | }, 92 | ], 93 | }, 94 | plugins: [ 95 | ...(requiredByDLLConfig 96 | ? [] 97 | : [ 98 | new webpack.DllReferencePlugin({ 99 | context: webpackPaths.dllPath, 100 | manifest: require(manifest), 101 | sourceType: 'var', 102 | }), 103 | ]), 104 | 105 | new webpack.NoEmitOnErrorsPlugin(), 106 | 107 | /** 108 | * Create global constants which can be configured at compile time. 109 | * 110 | * Useful for allowing different behaviour between development builds and 111 | * release builds 112 | * 113 | * NODE_ENV should be production so that modules do not perform certain 114 | * development checks 115 | * 116 | * By default, use 'development' as NODE_ENV. This can be overriden with 117 | * 'staging', for example, by changing the ENV variables in the npm scripts 118 | */ 119 | new webpack.EnvironmentPlugin({ 120 | NODE_ENV: 'development', 121 | }), 122 | 123 | new webpack.LoaderOptionsPlugin({ 124 | debug: true, 125 | }), 126 | 127 | new ReactRefreshWebpackPlugin(), 128 | 129 | new HtmlWebpackPlugin({ 130 | filename: path.join('index.html'), 131 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), 132 | minify: { 133 | collapseWhitespace: true, 134 | removeAttributeQuotes: true, 135 | removeComments: true, 136 | }, 137 | isBrowser: false, 138 | env: process.env.NODE_ENV, 139 | isDevelopment: process.env.NODE_ENV !== 'production', 140 | nodeModules: webpackPaths.appNodeModulesPath, 141 | }), 142 | ], 143 | 144 | node: { 145 | __dirname: false, 146 | __filename: false, 147 | }, 148 | 149 | // @ts-ignore 150 | devServer: { 151 | port, 152 | compress: true, 153 | hot: true, 154 | headers: { 'Access-Control-Allow-Origin': '*' }, 155 | static: { 156 | publicPath: '/', 157 | }, 158 | historyApiFallback: { 159 | verbose: true, 160 | }, 161 | onBeforeSetupMiddleware() { 162 | console.log('Starting Main Process...'); 163 | spawn('npm', ['run', 'start:main'], { 164 | shell: true, 165 | env: process.env, 166 | stdio: 'inherit', 167 | }) 168 | .on('close', (code: number) => process.exit(code!)) 169 | .on('error', (spawnError) => console.error(spawnError)); 170 | }, 171 | }, 172 | }; 173 | 174 | export default merge(baseConfig, configuration); 175 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build config for electron renderer process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; 11 | import { merge } from 'webpack-merge'; 12 | import TerserPlugin from 'terser-webpack-plugin'; 13 | import baseConfig from './webpack.config.base'; 14 | import webpackPaths from './webpack.paths'; 15 | import checkNodeEnv from '../scripts/check-node-env'; 16 | import deleteSourceMaps from '../scripts/delete-source-maps'; 17 | 18 | checkNodeEnv('production'); 19 | deleteSourceMaps(); 20 | 21 | const devtoolsConfig = 22 | process.env.DEBUG_PROD === 'true' 23 | ? { 24 | devtool: 'source-map', 25 | } 26 | : {}; 27 | 28 | const configuration: webpack.Configuration = { 29 | ...devtoolsConfig, 30 | 31 | mode: 'production', 32 | 33 | target: 'electron-renderer', 34 | 35 | entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], 36 | 37 | output: { 38 | path: webpackPaths.distRendererPath, 39 | publicPath: './', 40 | filename: 'renderer.js', 41 | }, 42 | 43 | module: { 44 | rules: [ 45 | { 46 | test: /\.s?(a|c)ss$/, 47 | use: [ 48 | MiniCssExtractPlugin.loader, 49 | { 50 | loader: 'css-loader', 51 | options: { 52 | modules: true, 53 | sourceMap: true, 54 | importLoaders: 1, 55 | }, 56 | }, 57 | 'sass-loader', 58 | ], 59 | include: /\.module\.s?(c|a)ss$/, 60 | }, 61 | { 62 | test: /\.s?(a|c)ss$/, 63 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 64 | exclude: /\.module\.s?(c|a)ss$/, 65 | }, 66 | // Fonts 67 | { 68 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 69 | type: 'asset/resource', 70 | }, 71 | // Images 72 | { 73 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 74 | type: 'asset/resource', 75 | }, 76 | ], 77 | }, 78 | 79 | optimization: { 80 | minimize: true, 81 | minimizer: [ 82 | new TerserPlugin({ 83 | parallel: true, 84 | }), 85 | new CssMinimizerPlugin(), 86 | ], 87 | }, 88 | 89 | plugins: [ 90 | /** 91 | * Create global constants which can be configured at compile time. 92 | * 93 | * Useful for allowing different behaviour between development builds and 94 | * release builds 95 | * 96 | * NODE_ENV should be production so that modules do not perform certain 97 | * development checks 98 | */ 99 | new webpack.EnvironmentPlugin({ 100 | NODE_ENV: 'production', 101 | DEBUG_PROD: false, 102 | }), 103 | 104 | new MiniCssExtractPlugin({ 105 | filename: 'style.css', 106 | }), 107 | 108 | new BundleAnalyzerPlugin({ 109 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 110 | }), 111 | 112 | new HtmlWebpackPlugin({ 113 | filename: 'index.html', 114 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), 115 | minify: { 116 | collapseWhitespace: true, 117 | removeAttributeQuotes: true, 118 | removeComments: true, 119 | }, 120 | isBrowser: false, 121 | isDevelopment: process.env.NODE_ENV !== 'production', 122 | }), 123 | ], 124 | }; 125 | 126 | export default merge(baseConfig, configuration); 127 | -------------------------------------------------------------------------------- /.erb/configs/webpack.paths.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const rootPath = path.join(__dirname, '../..'); 4 | 5 | const dllPath = path.join(__dirname, '../dll'); 6 | 7 | const srcPath = path.join(rootPath, 'src'); 8 | const srcMainPath = path.join(srcPath, 'main'); 9 | const srcRendererPath = path.join(srcPath, 'renderer'); 10 | 11 | const releasePath = path.join(rootPath, 'release'); 12 | const appPath = path.join(releasePath, 'app'); 13 | const appPackagePath = path.join(appPath, 'package.json'); 14 | const appNodeModulesPath = path.join(appPath, 'node_modules'); 15 | const srcNodeModulesPath = path.join(srcPath, 'node_modules'); 16 | 17 | const distPath = path.join(appPath, 'dist'); 18 | const distMainPath = path.join(distPath, 'main'); 19 | const distRendererPath = path.join(distPath, 'renderer'); 20 | 21 | const buildPath = path.join(releasePath, 'build'); 22 | 23 | export default { 24 | rootPath, 25 | dllPath, 26 | srcPath, 27 | srcMainPath, 28 | srcRendererPath, 29 | releasePath, 30 | appPath, 31 | appPackagePath, 32 | appNodeModulesPath, 33 | srcNodeModulesPath, 34 | distPath, 35 | distMainPath, 36 | distRendererPath, 37 | buildPath, 38 | }; 39 | -------------------------------------------------------------------------------- /.erb/img/erb-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/.erb/img/erb-logo.png -------------------------------------------------------------------------------- /.erb/mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /.erb/scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "global-require": "off", 5 | "import/no-dynamic-require": "off", 6 | "import/no-extraneous-dependencies": "off", 7 | "no-use-before-define": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.erb/scripts/check-build-exists.ts: -------------------------------------------------------------------------------- 1 | // Check if the renderer and main bundles are built 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import fs from 'fs'; 5 | import webpackPaths from '../configs/webpack.paths'; 6 | 7 | const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); 8 | const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); 9 | 10 | if (!fs.existsSync(mainPath)) { 11 | throw new Error( 12 | chalk.whiteBright.bgRed.bold( 13 | 'The main process is not built yet. Build it by running "npm run build:main"' 14 | ) 15 | ); 16 | } 17 | 18 | if (!fs.existsSync(rendererPath)) { 19 | throw new Error( 20 | chalk.whiteBright.bgRed.bold( 21 | 'The renderer process is not built yet. Build it by running "npm run build:renderer"' 22 | ) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.erb/scripts/check-native-dep.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import chalk from 'chalk'; 3 | import { execSync } from 'child_process'; 4 | import { dependencies } from '../../package.json'; 5 | 6 | if (dependencies) { 7 | const dependenciesKeys = Object.keys(dependencies); 8 | const nativeDeps = fs 9 | .readdirSync('node_modules') 10 | .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`)); 11 | if (nativeDeps.length === 0) { 12 | process.exit(0); 13 | } 14 | try { 15 | // Find the reason for why the dependency is installed. If it is installed 16 | // because of a devDependency then that is okay. Warn when it is installed 17 | // because of a dependency 18 | const { dependencies: dependenciesObject } = JSON.parse( 19 | execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString() 20 | ); 21 | const rootDependencies = Object.keys(dependenciesObject); 22 | const filteredRootDependencies = rootDependencies.filter((rootDependency) => 23 | dependenciesKeys.includes(rootDependency) 24 | ); 25 | if (filteredRootDependencies.length > 0) { 26 | const plural = filteredRootDependencies.length > 1; 27 | console.log(` 28 | ${chalk.whiteBright.bgYellow.bold( 29 | 'Webpack does not work with native dependencies.' 30 | )} 31 | ${chalk.bold(filteredRootDependencies.join(', '))} ${ 32 | plural ? 'are native dependencies' : 'is a native dependency' 33 | } and should be installed inside of the "./release/app" folder. 34 | First, uninstall the packages from "./package.json": 35 | ${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')} 36 | ${chalk.bold( 37 | 'Then, instead of installing the package to the root "./package.json":' 38 | )} 39 | ${chalk.whiteBright.bgRed.bold('npm install your-package')} 40 | ${chalk.bold('Install the package to "./release/app/package.json"')} 41 | ${chalk.whiteBright.bgGreen.bold('cd ./release/app && npm install your-package')} 42 | Read more about native dependencies at: 43 | ${chalk.bold( 44 | 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure' 45 | )} 46 | `); 47 | process.exit(1); 48 | } 49 | } catch (e) { 50 | console.log('Native dependencies could not be checked'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.erb/scripts/check-node-env.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export default function checkNodeEnv(expectedEnv) { 4 | if (!expectedEnv) { 5 | throw new Error('"expectedEnv" not set'); 6 | } 7 | 8 | if (process.env.NODE_ENV !== expectedEnv) { 9 | console.log( 10 | chalk.whiteBright.bgRed.bold( 11 | `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config` 12 | ) 13 | ); 14 | process.exit(2); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.erb/scripts/check-port-in-use.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import detectPort from 'detect-port'; 3 | 4 | const port = process.env.PORT || '1212'; 5 | 6 | detectPort(port, (err, availablePort) => { 7 | if (port !== String(availablePort)) { 8 | throw new Error( 9 | chalk.whiteBright.bgRed.bold( 10 | `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start` 11 | ) 12 | ); 13 | } else { 14 | process.exit(0); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /.erb/scripts/clean.js: -------------------------------------------------------------------------------- 1 | import rimraf from 'rimraf'; 2 | import webpackPaths from '../configs/webpack.paths.ts'; 3 | import process from 'process'; 4 | 5 | const args = process.argv.slice(2); 6 | const commandMap = { 7 | dist: webpackPaths.distPath, 8 | release: webpackPaths.releasePath, 9 | dll: webpackPaths.dllPath, 10 | }; 11 | 12 | args.forEach((x) => { 13 | const pathToRemove = commandMap[x]; 14 | if (pathToRemove !== undefined) { 15 | rimraf.sync(pathToRemove); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /.erb/scripts/delete-source-maps.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import rimraf from 'rimraf'; 3 | import webpackPaths from '../configs/webpack.paths'; 4 | 5 | export default function deleteSourceMaps() { 6 | rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map')); 7 | rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map')); 8 | } 9 | -------------------------------------------------------------------------------- /.erb/scripts/electron-rebuild.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { execSync } from 'child_process'; 3 | import fs from 'fs'; 4 | import { dependencies } from '../../release/app/package.json'; 5 | import webpackPaths from '../configs/webpack.paths'; 6 | 7 | if ( 8 | Object.keys(dependencies || {}).length > 0 && 9 | fs.existsSync(webpackPaths.appNodeModulesPath) 10 | ) { 11 | const electronRebuildCmd = 12 | '../../node_modules/.bin/electron-rebuild --parallel --force --types prod,dev,optional --module-dir .'; 13 | const cmd = 14 | process.platform === 'win32' 15 | ? electronRebuildCmd.replace(/\//g, '\\') 16 | : electronRebuildCmd; 17 | execSync(cmd, { 18 | cwd: webpackPaths.appPath, 19 | stdio: 'inherit', 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /.erb/scripts/link-modules.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import webpackPaths from '../configs/webpack.paths'; 3 | 4 | const srcNodeModulesPath = webpackPaths.srcNodeModulesPath; 5 | const appNodeModulesPath = webpackPaths.appNodeModulesPath 6 | 7 | if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { 8 | fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); 9 | } 10 | -------------------------------------------------------------------------------- /.erb/scripts/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('electron-notarize'); 2 | const { build } = require('../../package.json'); 3 | 4 | exports.default = async function notarizeMacos(context) { 5 | const { electronPlatformName, appOutDir } = context; 6 | if (electronPlatformName !== 'darwin') { 7 | return; 8 | } 9 | 10 | if (process.env.CI !== "true") { 11 | console.warn('Skipping notarizing step. Packaging is not running in CI'); 12 | return; 13 | } 14 | 15 | if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) { 16 | console.warn('Skipping notarizing step. APPLE_ID and APPLE_ID_PASS env variables must be set'); 17 | return; 18 | } 19 | 20 | const appName = context.packager.appInfo.productFilename; 21 | 22 | await notarize({ 23 | appBundleId: build.appId, 24 | appPath: `${appOutDir}/${appName}.app`, 25 | appleId: process.env.APPLE_ID, 26 | appleIdPassword: process.env.APPLE_ID_PASS, 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | .eslintcache 13 | 14 | # Dependency directory 15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 16 | node_modules 17 | 18 | # OSX 19 | .DS_Store 20 | 21 | release/app/dist 22 | release/build 23 | .erb/dll 24 | src 25 | .idea 26 | npm-debug.log.* 27 | package-lock.json 28 | *.css.d.ts 29 | *.sass.d.ts 30 | *.scss.d.ts 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'erb', 3 | rules: { 4 | // A temporary hack related to IDE not resolving correct package.json 5 | 'import/no-extraneous-dependencies': 'off', 6 | 'import/no-unresolved': 'error', 7 | // Since React 17 and typescript 4.1 you can safely disable the rule 8 | 'react/react-in-jsx-scope': 'off', 9 | 'no-use-before-define': 'off', 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 2020, 13 | sourceType: 'module', 14 | project: './tsconfig.json', 15 | tsconfigRootDir: __dirname, 16 | createDefaultProgram: true, 17 | }, 18 | settings: { 19 | 'import/resolver': { 20 | // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below 21 | node: {}, 22 | webpack: { 23 | config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), 24 | }, 25 | typescript: {}, 26 | }, 27 | 'import/parsers': { 28 | '@typescript-eslint/parser': ['.ts', '.tsx'], 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.exe binary 3 | *.png binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.ico binary 7 | *.icns binary 8 | *.eot binary 9 | *.otf binary 10 | *.ttf binary 11 | *.woff binary 12 | *.woff2 binary 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | # Workflow's jobs 9 | jobs: 10 | # job's id 11 | release: 12 | # job's name 13 | name: build and release electron app 14 | 15 | # the type of machine to run the job on 16 | runs-on: ${{ matrix.os }} 17 | 18 | # create a build matrix for jobs 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [windows-latest, macos-latest] 23 | 24 | # create steps 25 | steps: 26 | # step1: check out repository 27 | - name: Checkout git repo 28 | uses: actions/checkout@v2 29 | 30 | # step2: setup node env 31 | - name: Install Node and NPM 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version: 18 35 | cache: npm 36 | 37 | # step3: npm install and package 38 | - name: Install and build 39 | run: | 40 | npm install 41 | npm run postinstall 42 | npm run package 43 | 44 | # step4: cleanup artifacts in release 45 | - name: Cleanup artifacts for windows 46 | if: matrix.os == 'windows-latest' 47 | run: | 48 | npx rimraf "release/build/!(*.exe)" 49 | 50 | - name: Cleanup artifacts for macos 51 | if: matrix.os == 'macos-latest' 52 | run: | 53 | npx rimraf "release/build/!(*.dmg)" 54 | 55 | # step5: upload artifacts 56 | - name: Upload artifacts 57 | uses: actions/upload-artifact@v2 58 | with: 59 | name: ${{ matrix.os }} 60 | path: release/build 61 | 62 | # step6: create release 63 | - name: Create release 64 | uses: softprops/action-gh-release@v1 65 | if: startsWith(github.ref, 'refs/tags/') 66 | with: 67 | files: 'release/build/**' 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | .eslintcache 13 | 14 | # Dependency directory 15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 16 | node_modules 17 | 18 | # OSX 19 | .DS_Store 20 | 21 | release/app/dist 22 | release/build 23 | .erb/dll 24 | 25 | .idea 26 | .vscode 27 | npm-debug.log.* 28 | *.css.d.ts 29 | *.sass.d.ts 30 | *.scss.d.ts 31 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.0.9 2 | 3 | - electron 版本更新 4 | - websocket 认证 5 | - 优化头像存储功能 6 | - 添加用户 session 配置 7 | 8 | ## v1.0.8 9 | 10 | - 添加鼠标穿透功能 11 | - 优化头像存储功能 12 | 13 | ## v1.0.7 14 | 15 | - 添加打开时记住上次窗口大小位置 16 | - 添加警告类弹幕消息提示 17 | - 添加查看历史留言功能 18 | - 添加可屏蔽舰长消息文字特效功能 19 | 20 | ## v1.0.6 21 | 22 | - electron 版本更新,使用 reduxjs/toolkit 状态管理,功能优化 23 | - 移除礼物弹幕过渡动画 24 | - 添加自定义字体功能 25 | - 优化置顶功能 26 | - 优化顶部栏拖动功能 27 | - 优化点击弹幕朗读队列 28 | - 替换版本号 api 29 | 30 | ## v1.0.5 31 | 32 | - 弹幕翻译已被废弃 33 | - 修复百度语音 TTS 返回失败 bug 34 | 35 | ## v1.0.4 36 | 37 | - 优化新版粉丝勋章样式 38 | - 替换版本号 api,防止获取版本号失败 39 | - 修复礼物弹幕图片不显示 bug 40 | 41 | ## v1.0.3 42 | 43 | - 弹幕列表与礼物列表分开 44 | - 礼物列表拖动定位 45 | - 优化获取用户头像 api 访问受限 46 | - 修复 windows 下字体文件缺失 bug 47 | 48 | ## v1.0.2 49 | 50 | - 优化 config 类型 51 | - 添加 ref 类型 52 | - 移除 less,使用 scss 53 | - 优化 socket parseData 54 | - 添加单元测试 55 | - 修复 maxMessageCount 不更新问题 56 | - 修复 mac 无法复制粘贴 57 | 58 | ## v1.0.1 59 | 60 | - 优化版本号检查 61 | - win 和 mac 顶部窗口菜单栏保持一致 62 | - 修复客户端版本号错误 bug 63 | - 文档优化 64 | 65 | ## v1.0.1-beta.0 66 | 67 | - 完成初版功能 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Beats0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bilive-danmaku 2 | 3 |
4 | 5 | ![logo](https://beats0.github.io/bilive-danmaku/assets/icons/96x96.png) 6 |
7 | 一个开源的 bilibili 直播弹幕姬,支持 win 和 mac 8 |
9 | 10 |
11 | 12 | ### 预览 13 | 14 | ![https://wx1.sinaimg.cn/large/006nOlwNly1gfiygt1rr4j31hc0tvqv5.jpg](https://wx1.sinaimg.cn/large/006nOlwNly1gfiygt1rr4j31hc0tvqv5.jpg) 15 | 16 | [视频预览](https://www.bilibili.com/video/av328551804) 17 | 18 | ### 使用 19 | 20 | [下载 Release](https://github.com/Beats0/bilive-danmaku/releases) 21 | 22 | 输入房间号 RoomID 后,回车提交即可连接 23 | 24 | ### 功能 25 | 26 | 面板和官方 web 端几乎一模一样,主要拓展了订阅列表,~~弹幕翻译~~,语音朗读,多语言配置等功能 27 | 28 | 支持的消息类型 29 | 30 | ``` 31 | LIVE // 开播消息 32 | POPULAR // 人气 33 | WATCHED_CHANGE // 直播间看过人数 34 | DANMU_MSG // 弹幕消息 35 | SEND_GIFT // 礼物消息 36 | SPECIAL_GIFT // TODO 37 | COMBO_SEND // 礼物连击消息 38 | COMBO_END // TODO 礼物连击结束消息 39 | NOTICE_MSG // 广播消息 40 | WELCOME // 欢迎进入直播间(不会触发) 41 | WELCOME_GUARD // 欢迎舰长进入直播间(不会触发) 42 | ENTRY_EFFECT // 舰长、高能榜、老爷进入直播间 43 | INTERACT_WORD // 用户进入直播间,用户关注直播间 44 | ROOM_BLOCK_MSG // 用户被禁言 45 | GUARD_BUY // 上舰消息 46 | SUPER_CHAT_MESSAGE // SC消息 47 | WARNING // 直播警告消息 48 | CUT_OFF // 直播强制切断消息 49 | ``` 50 | 51 | ### 注意 Note! 52 | 53 | 1. 登录认证(可选) 54 | 55 | 由于 bilibili 隐私限制, 未登录情况下无法查看他人昵称。为了更好的体验可在浏览器登录 bilibili 后,在控制台 cookie 中选择 SESSDATA 的值, 复制粘贴到以下输入框点击刷新即可。 56 | 57 | 58 | 59 | 2. ~~翻译~~和朗读(翻译已失效) 60 | 61 | ~~大量使用 google translate api,超出官方调用频率会导致请求超时,翻译或朗读失败。~~ 62 | 63 | 3. 鼠标穿透功能 64 | 65 | 点击顶部穿透按钮后可开启鼠标穿透功能,再次点击可取消解锁 66 | 67 | 4. 自定义样式(仅支持昵称样式和弹幕样式) 68 | 69 | 点击 Dev Tools,编写对应的编辑 CSS 样式,只复制 css 声明语句,例如上图的 css 为 70 | 71 | ```css 72 | text-shadow: 1px 1px 2px #e91e63, 0 0 0.2em #e91e63; 73 | ``` 74 | 75 | 填入到 `设置` > `自定义样式` 中,`Ctrl+R` 重载即可。 76 | 77 | ### 开发 78 | 79 | [README_DEV](https://github.com/Beats0/bilive-danmaku/blob/master/README_DEV.md) 80 | 81 | ### [更新日志](https://github.com/Beats0/bilive-danmaku/blob/master/CHANGELOG.md) 82 | 83 | ### LICENSE 84 | 85 | [MIT](https://github.com/Beats0/bilive-danmaku/blob/master/LICENSE) 86 | 87 | [MIT © Electron React Boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) 88 | -------------------------------------------------------------------------------- /README_DEV.md: -------------------------------------------------------------------------------- 1 | # DEV 2 | 3 | 开发文档 4 | 5 | 设置源 6 | 7 | ``` 8 | npm config set registry https://registry.npmmirror.com 9 | yarn config set registry https://registry.npmmirror.com 10 | yarn config set disturl https://registry.npmmirror.com/dist 11 | yarn config set electron_mirror https://registry.npmmirror.com/electron/ 12 | 13 | 14 | npm config set disturl=https://registry.npmmirror.com/-/binary/node 15 | npm config set ELECTRON_MIRROR=https://registry.npmmirror.com/-/binary/electron/ 16 | yarn config set disturl https://registry.npmmirror.com/-/binary/node -g 17 | ``` 18 | 19 | eslint 配置不生效,JSX 代码格式各种警告,实在搞不动了,有代码强迫症的把 eslint 检查关了吧 = = 20 | 21 | ## 命令 22 | 23 | 调试 24 | 25 | ```sh 26 | # install 27 | $ npm intall 28 | 29 | # run dev 30 | $ npm run start 31 | 32 | # 打包 33 | $ npm run package 34 | ``` 35 | 36 | 默认打包将输出到 release 文件夹中 37 | 38 | ## 快速生成 icns 图标 39 | 40 | ``` 41 | sips -z 16 16 pic.png --out icon/icon_16x16.png 42 | sips -z 24 24 pic.png --out icon/icon_24x24.png 43 | sips -z 32 32 pic.png --out icon/icon_32x32.png 44 | sips -z 48 48 pic.png --out icon/icon_48x48.png 45 | sips -z 64 64 pic.png --out icon/icon_64x64.png 46 | sips -z 96 96 pic.png --out icon/icon_96x96.png 47 | sips -z 128 128 pic.png --out icon/icon_128x128.png 48 | sips -z 256 256 pic.png --out icon/icon_256x256.png 49 | sips -z 512 512 pic.png --out icon/icon_512x512.png 50 | sips -z 1024 1024 pic.png --out icon/icon_1024x1024.png 51 | ``` 52 | 53 | ```sh 54 | iconutil -c icns icon -o Icon.icns 55 | ``` 56 | -------------------------------------------------------------------------------- /assets/assets.d.ts: -------------------------------------------------------------------------------- 1 | type Styles = Record; 2 | 3 | declare module '*.svg' { 4 | const content: string; 5 | export default content; 6 | } 7 | 8 | declare module '*.png' { 9 | const content: string; 10 | export default content; 11 | } 12 | 13 | declare module '*.jpg' { 14 | const content: string; 15 | export default content; 16 | } 17 | 18 | declare module '*.scss' { 19 | const content: Styles; 20 | export default content; 21 | } 22 | 23 | declare module '*.sass' { 24 | const content: Styles; 25 | export default content; 26 | } 27 | 28 | declare module '*.css' { 29 | const content: Styles; 30 | export default content; 31 | } 32 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/fonts/lolita.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/fonts/lolita.ttf -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /assets/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/1024x1024.png -------------------------------------------------------------------------------- /assets/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/128x128.png -------------------------------------------------------------------------------- /assets/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/16x16.png -------------------------------------------------------------------------------- /assets/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/24x24.png -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/32x32.png -------------------------------------------------------------------------------- /assets/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/48x48.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/64x64.png -------------------------------------------------------------------------------- /assets/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/assets/icons/96x96.png -------------------------------------------------------------------------------- /extraResources/fontlist/getSystemFonts.js: -------------------------------------------------------------------------------- 1 | const { getFonts } = require('./index'); 2 | 3 | function getSystemFonts() { 4 | return new Promise((resolve, reject) => { 5 | getFonts({ disableQuoting: true }) 6 | .then((fonts) => { 7 | fonts = [...new Set(fonts)]; 8 | resolve(fonts || []); 9 | }) 10 | .catch((err) => { 11 | resolve([]); 12 | }); 13 | }); 14 | } 15 | 16 | (async () => { 17 | const fonts = await getSystemFonts(); 18 | process.send(fonts); 19 | // console.log('fonts', fonts); 20 | })(); 21 | -------------------------------------------------------------------------------- /extraResources/fontlist/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * index.d.ts 3 | * @author: oldj 4 | * @homepage: https://oldj.net 5 | */ 6 | 7 | interface IOptions { 8 | disableQuoting: boolean; 9 | } 10 | 11 | type FontList = string[] 12 | 13 | export function getFonts (options?: IOptions): Promise; 14 | -------------------------------------------------------------------------------- /extraResources/fontlist/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author oldj 3 | * @blog http://oldj.net 4 | */ 5 | 6 | 'use strict' 7 | 8 | const platform = process.platform 9 | 10 | let getFontsFunc 11 | switch (platform) { 12 | case 'darwin': 13 | getFontsFunc = require('./libs/darwin') 14 | break 15 | case 'win32': 16 | getFontsFunc = require('./libs/win32') 17 | break 18 | case 'linux': 19 | getFontsFunc = require('./libs/linux') 20 | break 21 | default: 22 | throw new Error(`Error: font-list can not run on ${platform}.`) 23 | } 24 | 25 | const defaultOptions = { 26 | disableQuoting: false 27 | } 28 | 29 | exports.getFonts = async (options) => { 30 | options = Object.assign({}, defaultOptions, options) 31 | 32 | let fonts = await getFontsFunc() 33 | 34 | fonts = fonts.map(i => { 35 | // parse unicode names, eg: '"\\U559c\\U9e4a\\U805a\\U73cd\\U4f53"' -> '"喜鹊聚珍体"' 36 | try { 37 | i = i.replace(/\\u([\da-f]{4})/ig, (m, s) => String.fromCharCode(parseInt(s, 16))) 38 | } catch (e) { 39 | console.log(e) 40 | } 41 | 42 | if (options && options.disableQuoting) { 43 | if (i.startsWith('"') && i.endsWith('"')) { 44 | i = `${i.substr(1, i.length - 2)}` 45 | } 46 | } else if (i.includes(' ') && !i.startsWith('"')) { 47 | i = `"${i}"` 48 | } 49 | 50 | return i 51 | }) 52 | 53 | fonts.sort((a, b) => { 54 | return a.replace(/^['"]+/, '').toLocaleLowerCase() < b.replace(/^['"]+/, '').toLocaleLowerCase() ? -1 : 1 55 | }) 56 | 57 | return fonts 58 | } 59 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/darwin/fontlist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/extraResources/fontlist/libs/darwin/fontlist -------------------------------------------------------------------------------- /extraResources/fontlist/libs/darwin/fontlist.m: -------------------------------------------------------------------------------- 1 | #import 2 | 3 | int main(int argc, const char * argv[]) { 4 | @autoreleasepool { 5 | NSLog(@"%@", [[[NSFontManager sharedFontManager] availableFontFamilies] description]); 6 | } 7 | return 0; 8 | } 9 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/darwin/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * index 3 | * @author oldj 4 | * @blog https://oldj.net 5 | */ 6 | 7 | 'use strict' 8 | 9 | const path = require('path') 10 | const execFile = require('child_process').execFile 11 | 12 | const bin = path.join(__dirname, 'fontlist') 13 | const font_exceptions = ['iconfont'] 14 | 15 | function tryToGetFonts(s) { 16 | let fonts = [] 17 | let m = s.match(/\(([\s\S]+)\)/) 18 | if (m) { 19 | fonts = m[1].split('\n') 20 | .map(i => i.trim()) 21 | .map(i => i.replace(/,$/, '')) 22 | } 23 | 24 | return fonts 25 | } 26 | 27 | module.exports = () => new Promise((resolve, reject) => { 28 | execFile(bin, { maxBuffer: 1024 * 1024 * 10 }, (error, stdout, stderr) => { 29 | if (error) { 30 | reject(error) 31 | return 32 | } 33 | 34 | let fonts = [] 35 | if (stdout) { 36 | fonts = fonts.concat(tryToGetFonts(stdout)) 37 | } 38 | if (stderr) { 39 | fonts = fonts.concat(tryToGetFonts(stderr)) 40 | } 41 | 42 | fonts = Array.from(new Set(fonts)) 43 | .filter(i => i && !font_exceptions.includes(i)) 44 | 45 | resolve(fonts) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/linux/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * index 3 | * @author: oldj 4 | * @homepage: https://oldj.net 5 | */ 6 | 7 | const exec = require('child_process').exec 8 | const util = require('util') 9 | 10 | const pexec = util.promisify(exec) 11 | 12 | async function binaryExists(binary) { 13 | const { stdout } = await pexec(`whereis ${binary}`) 14 | return stdout.length > (binary.length + 2) 15 | } 16 | 17 | module.exports = async () => { 18 | const fcListBinary = await binaryExists('fc-list') 19 | ? 'fc-list' 20 | : 'fc-list2' 21 | 22 | const cmd = fcListBinary + ' -f "%{family[0]}\\n"' 23 | 24 | const { stdout } = await pexec(cmd, { maxBuffer: 1024 * 1024 * 10 }) 25 | const fonts = stdout.split('\n').filter(f => !!f) 26 | 27 | return Array.from(new Set(fonts)) 28 | } 29 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/win32/fonts.vbs: -------------------------------------------------------------------------------- 1 | Option Explicit 2 | 3 | Dim objShell, objFSO, objFile, objFolder 4 | Dim objFolderItem, colItems, objFont 5 | Dim strFileName 6 | 7 | 8 | Const FONTS = &H14& ' Fonts Folder 9 | 10 | ' Instantiate Objects 11 | Set objShell = CreateObject("Shell.Application") 12 | Set objFolder = objShell.Namespace(FONTS) 13 | Set objFolderItem = objFolder.Self 14 | Set colItems = objFolder.Items 15 | Set objFSO = CreateObject("Scripting.FileSystemObject") 16 | 17 | For Each objFont in colItems 18 | WScript.StdOut.WriteLine(objFont.Path & vbtab & objFont.Name) 19 | Next 20 | 21 | Set objShell = nothing 22 | Set objFile = nothing 23 | Set objFolder = nothing 24 | Set objFolderItem = nothing 25 | Set colItems = nothing 26 | Set objFont = nothing 27 | Set objFSO = nothing 28 | 29 | wscript.quit 30 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/win32/getByPowerShell.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getByPowerShell 3 | * @author: oldj 4 | * @homepage: https://oldj.net 5 | */ 6 | 7 | const exec = require('child_process').exec 8 | 9 | const parse = (str) => { 10 | return str 11 | .split('\n') 12 | .map(ln => ln.trim()) 13 | .filter(f => !!f) 14 | } 15 | 16 | /* 17 | @see https://superuser.com/questions/760627/how-to-list-installed-font-families 18 | 19 | chcp 65001 | Out-Null 20 | Add-Type -AssemblyName PresentationCore 21 | $families = [Windows.Media.Fonts]::SystemFontFamilies 22 | foreach ($family in $families) { 23 | $name = '' 24 | if (!$family.FamilyNames.TryGetValue([Windows.Markup.XmlLanguage]::GetLanguage('zh-cn'), [ref]$name)) { 25 | $name = $family.FamilyNames[[Windows.Markup.XmlLanguage]::GetLanguage('en-us')] 26 | } 27 | echo $name 28 | } 29 | */ 30 | module.exports = () => new Promise((resolve, reject) => { 31 | let cmd = `chcp 65001|powershell -command "chcp 65001|Out-Null;Add-Type -AssemblyName PresentationCore;$families=[Windows.Media.Fonts]::SystemFontFamilies;foreach($family in $families){$name='';if(!$family.FamilyNames.TryGetValue([Windows.Markup.XmlLanguage]::GetLanguage('zh-cn'),[ref]$name)){$name=$family.FamilyNames[[Windows.Markup.XmlLanguage]::GetLanguage('en-us')]}echo $name}"` 32 | 33 | exec(cmd, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout) => { 34 | if (err) { 35 | reject(err) 36 | return 37 | } 38 | 39 | resolve(parse(stdout)) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/win32/getByVBS.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getByVBS 3 | * @author: oldj 4 | * @homepage: https://oldj.net 5 | */ 6 | 7 | const os = require('os') 8 | const fs = require('fs') 9 | const path = require('path') 10 | const execFile = require('child_process').execFile 11 | const util = require('util') 12 | 13 | const p_copyFile = util.promisify(fs.copyFile) 14 | 15 | function tryToGetFonts(s) { 16 | let a = s.split('\n') 17 | if (a[0].includes('Microsoft')) { 18 | a.splice(0, 3) 19 | } 20 | 21 | a = a.map(i => { 22 | i = i 23 | .split('\t')[0] 24 | .split(path.sep) 25 | i = i[i.length - 1] 26 | 27 | if (!i.match(/^[\w\s]+$/)) { 28 | i = '' 29 | } 30 | 31 | i = i 32 | .replace(/^\s+|\s+$/g, '') 33 | .replace(/(Regular|常规)$/i, '') 34 | .replace(/^\s+|\s+$/g, '') 35 | 36 | return i 37 | }) 38 | 39 | return a.filter(i => i) 40 | } 41 | 42 | async function writeToTmpDir(fn) { 43 | let tmp_fn = path.join(os.tmpdir(), 'node-font-list-fonts.vbs') 44 | await p_copyFile(fn, tmp_fn) 45 | return tmp_fn 46 | } 47 | 48 | module.exports = async () => { 49 | let fn = path.join(__dirname, 'fonts.vbs') 50 | 51 | const is_in_asar = fn.includes('app.asar') 52 | if (is_in_asar) { 53 | fn = await writeToTmpDir(fn) 54 | } 55 | 56 | return new Promise((resolve, reject) => { 57 | let cmd = `cscript` 58 | 59 | execFile(cmd, [fn], { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => { 60 | let fonts = [] 61 | 62 | if (err) { 63 | reject(err) 64 | return 65 | } 66 | 67 | if (stdout) { 68 | //require('electron').dialog.showMessageBox({message: 'stdout: ' + stdout}) 69 | fonts = fonts.concat(tryToGetFonts(stdout)) 70 | } 71 | if (stderr) { 72 | //require('electron').dialog.showMessageBox({message: 'stderr: ' + stderr}) 73 | fonts = fonts.concat(tryToGetFonts(stderr)) 74 | } 75 | 76 | resolve(fonts) 77 | }) 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /extraResources/fontlist/libs/win32/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * index 3 | * @author oldj 4 | * @blog https://oldj.net 5 | */ 6 | 7 | 'use strict' 8 | 9 | const os = require('os') 10 | const getByPowerShell = require('./getByPowerShell') 11 | const getByVBS = require('./getByVBS') 12 | 13 | const methods_new = [getByPowerShell, getByVBS] 14 | const methods_old = [getByVBS, getByPowerShell] 15 | 16 | module.exports = async () => { 17 | let fonts = [] 18 | 19 | // @see {@link https://stackoverflow.com/questions/42524606/how-to-get-windows-version-using-node-js} 20 | let os_v = parseInt(os.release()) 21 | let methods = os_v >= 10 ? methods_new : methods_old 22 | 23 | for (let method of methods) { 24 | try { 25 | fonts = await method() 26 | if (fonts.length > 0) break 27 | } catch (e) { 28 | console.log(e) 29 | } 30 | } 31 | 32 | return fonts 33 | } 34 | -------------------------------------------------------------------------------- /release/app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilive-danmaku", 3 | "version": "1.0.9", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "bilive-danmaku", 9 | "version": "1.0.9", 10 | "hasInstallScript": true, 11 | "license": "MIT" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /release/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilive-danmaku", 3 | "version": "1.0.9", 4 | "description": "bilibili live danmaku client", 5 | "main": "./dist/main/main.js", 6 | "author": { 7 | "name": "Beats0", 8 | "email": "Beats01998@gmail.com", 9 | "url": "https://github.com/Beats0" 10 | }, 11 | "scripts": { 12 | "electron-rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js", 13 | "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts", 14 | "postinstall": "npm run electron-rebuild && npm run link-modules" 15 | }, 16 | "dependencies": {}, 17 | "license": "MIT" 18 | } 19 | -------------------------------------------------------------------------------- /release/app/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /screenshot/session.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/screenshot/session.png -------------------------------------------------------------------------------- /src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render } from '@testing-library/react'; 3 | import App from '../renderer/App'; 4 | 5 | describe('App', () => { 6 | it('should render', () => { 7 | expect(render()).toBeTruthy(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint global-require: off, no-console: off, promise/always-return: off */ 2 | 3 | /** 4 | * This module executes inside of electron's main process. You can start 5 | * electron renderer process from here and communicate with the other processes 6 | * through IPC. 7 | * 8 | * When running `npm run build` or `npm run build:main`, this file is compiled to 9 | * `./src/main.js` using webpack. This gives us some performance wins. 10 | */ 11 | import path from 'path'; 12 | import cp from 'child_process'; 13 | import { app, BrowserWindow, ipcMain, session, shell } from 'electron'; 14 | import { autoUpdater } from 'electron-updater'; 15 | import log from 'electron-log'; 16 | import { windowStateKeeper } from './stateKeeper'; 17 | import MenuBuilder from './menu'; 18 | import { resolveHtmlPath } from './util'; 19 | 20 | export default class AppUpdater { 21 | constructor() { 22 | log.transports.file.level = 'info'; 23 | autoUpdater.logger = log; 24 | autoUpdater.checkForUpdatesAndNotify(); 25 | } 26 | } 27 | 28 | let mainWindow: BrowserWindow | null = null; 29 | 30 | if (process.env.NODE_ENV === 'production') { 31 | const sourceMapSupport = require('source-map-support'); 32 | sourceMapSupport.install(); 33 | } 34 | 35 | const isDevelopment = 36 | process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; 37 | 38 | if (isDevelopment) { 39 | require('electron-debug')(); 40 | } 41 | 42 | const installExtensions = async () => { 43 | const installer = require('electron-devtools-installer'); 44 | const forceDownload = !!process.env.UPGRADE_EXTENSIONS; 45 | const extensions = ['REACT_DEVELOPER_TOOLS']; 46 | 47 | return installer 48 | .default( 49 | extensions.map((name) => installer[name]), 50 | forceDownload 51 | ) 52 | .catch(console.log); 53 | }; 54 | 55 | const createWindow = async () => { 56 | // if (isDevelopment) { 57 | // await installExtensions(); 58 | // } 59 | 60 | const RESOURCES_PATH = app.isPackaged 61 | ? path.join(process.resourcesPath, 'assets') 62 | : path.join(__dirname, '../../assets'); 63 | 64 | const EXTRARESOURCES_PATH = app.isPackaged 65 | ? path.join(process.resourcesPath, 'extraResources') 66 | : path.join(__dirname, '../../extraResources'); 67 | 68 | const getAssetPath = (...paths: string[]): string => { 69 | return path.join(RESOURCES_PATH, ...paths); 70 | }; 71 | const getExtraResourcesPath = (...paths: string[]): string => { 72 | return path.join(EXTRARESOURCES_PATH, ...paths); 73 | }; 74 | // 保存窗口位置大小信息 75 | const mainWindowStateKeeper = await windowStateKeeper('main'); 76 | 77 | mainWindow = new BrowserWindow({ 78 | show: false, 79 | x: mainWindowStateKeeper.x, 80 | y: mainWindowStateKeeper.y, 81 | width: mainWindowStateKeeper.width, 82 | height: mainWindowStateKeeper.height, 83 | minWidth: 300, 84 | frame: false, 85 | transparent: true, 86 | icon: getAssetPath('icon.png'), 87 | webPreferences: { 88 | nodeIntegration: true, 89 | contextIsolation: false, 90 | webSecurity: false, 91 | // preload: path.join(__dirname, 'preload.js'), 92 | }, 93 | }); 94 | 95 | mainWindow.loadURL(resolveHtmlPath('index.html')); 96 | 97 | // mainWindowStateKeeper track 加载窗口位置大小信息 98 | await mainWindowStateKeeper.track(mainWindow); 99 | 100 | mainWindow.on('ready-to-show', () => { 101 | if (!mainWindow) { 102 | throw new Error('"mainWindow" is not defined'); 103 | } 104 | if (process.env.START_MINIMIZED) { 105 | mainWindow.minimize(); 106 | } else { 107 | mainWindow.show(); 108 | } 109 | }); 110 | mainWindow.on('closed', () => { 111 | mainWindow = null; 112 | }); 113 | 114 | ipcMain.on('toggleDevTools', () => { 115 | mainWindow?.webContents.toggleDevTools(); 116 | }); 117 | ipcMain.on('setAlwaysOnTop', (_event, flag: boolean) => { 118 | if (flag) { 119 | mainWindow?.setVisibleOnAllWorkspaces(true, { 120 | visibleOnFullScreen: true, 121 | }); 122 | mainWindow?.setAlwaysOnTop(true, 'screen-saver', 1); 123 | } else { 124 | mainWindow?.setAlwaysOnTop(false); 125 | } 126 | }); 127 | ipcMain.on('setIgnoreMouse', (_event, flag: boolean) => { 128 | if (flag) { 129 | mainWindow?.setIgnoreMouseEvents(true, { forward: true }); 130 | } else { 131 | mainWindow?.setIgnoreMouseEvents(false, { forward: false }); 132 | } 133 | }); 134 | ipcMain.on('closeApp', async () => { 135 | if (process.platform !== 'darwin') { 136 | app.quit(); 137 | } else { 138 | app.exit(); 139 | } 140 | }); 141 | ipcMain.on('getSystemFonts', async () => { 142 | const systemFontsScriptPath = getExtraResourcesPath('fontlist/getSystemFonts.js') 143 | let fonts: string[] = []; 144 | const forked = cp.fork(systemFontsScriptPath); 145 | forked.on('message', function (message: string[]) { 146 | fonts = message; 147 | mainWindow?.webContents.send('getSystemFontsCb', fonts); 148 | }); 149 | }); 150 | 151 | const menuBuilder = new MenuBuilder(mainWindow); 152 | menuBuilder.buildMenu(); 153 | 154 | // Open urls in the user's browser 155 | mainWindow.webContents.setWindowOpenHandler((edata) => { 156 | shell.openExternal(edata.url); 157 | return { action: 'deny' }; 158 | }); 159 | 160 | // 防止 bilibili referer 403 161 | const filter = { 162 | urls: ['*://*.bilibili.com/*', '*://*.hdslb.com/*'], 163 | }; 164 | session.defaultSession.webRequest.onBeforeSendHeaders( 165 | filter, 166 | (details, cb) => { 167 | details.requestHeaders.referer = 'https://www.bilibili.com'; 168 | const data = { requestHeaders: details.requestHeaders }; 169 | cb(data); 170 | } 171 | ); 172 | 173 | // Remove this if your app does not use auto updates 174 | // eslint-disable-next-line 175 | new AppUpdater(); 176 | }; 177 | 178 | /** 179 | * Add event listeners... 180 | */ 181 | 182 | app.on('window-all-closed', () => { 183 | // Respect the OSX convention of having the application in memory even 184 | // after all windows have been closed 185 | if (process.platform !== 'darwin') { 186 | app.quit(); 187 | } 188 | }); 189 | 190 | app 191 | .whenReady() 192 | .then(() => { 193 | createWindow(); 194 | app.on('activate', () => { 195 | // On macOS it's common to re-create a window in the app when the 196 | // dock icon is clicked and there are no other windows open. 197 | if (mainWindow === null) createWindow(); 198 | }); 199 | }) 200 | .catch(console.log); 201 | -------------------------------------------------------------------------------- /src/main/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | contextBridge.exposeInMainWorld('electron', { 4 | ipcRenderer: { 5 | myPing() { 6 | ipcRenderer.send('ipc-example', 'ping'); 7 | }, 8 | on(channel, func) { 9 | const validChannels = ['ipc-example']; 10 | if (validChannels.includes(channel)) { 11 | // Deliberately strip event as it includes `sender` 12 | ipcRenderer.on(channel, (event, ...args) => func(...args)); 13 | } 14 | }, 15 | once(channel, func) { 16 | const validChannels = ['ipc-example']; 17 | if (validChannels.includes(channel)) { 18 | // Deliberately strip event as it includes `sender` 19 | ipcRenderer.once(channel, (event, ...args) => func(...args)); 20 | } 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/main/stateKeeper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * see https://stackoverflow.com/questions/51328586/how-to-restore-default-window-size-in-an-electron-app 3 | * * */ 4 | 5 | // import { screen } from 'electron'; 6 | import settings from 'electron-settings'; 7 | import BrowserWindow = Electron.BrowserWindow; 8 | 9 | interface WindowState { 10 | x: number | undefined; 11 | y: number | undefined; 12 | width: number; 13 | height: number; 14 | isMaximized?: boolean; 15 | } 16 | 17 | export const windowStateKeeper = async (windowName: string) => { 18 | let window: BrowserWindow; 19 | let windowState: WindowState; 20 | 21 | const setBounds = async () => { 22 | // Restore from appConfig 23 | if (await settings.has(`windowState.${windowName}`)) { 24 | windowState = await settings.get(`windowState.${windowName}`); 25 | return; 26 | } 27 | 28 | // const size = screen.getPrimaryDisplay().workAreaSize; 29 | 30 | // Default 31 | windowState = { 32 | x: undefined, 33 | y: undefined, 34 | width: 390, 35 | height: 800, 36 | // width: size.width / 2, 37 | // height: size.height / 2, 38 | }; 39 | }; 40 | 41 | const saveState = async () => { 42 | // bug: lots of save state events are called. they should be debounced 43 | if (!windowState.isMaximized) { 44 | windowState = window.getBounds(); 45 | } 46 | windowState.isMaximized = window.isMaximized(); 47 | await settings.set(`windowState.${windowName}`, windowState); 48 | }; 49 | 50 | const track = async (win) => { 51 | window = win; 52 | ['resize', 'move', 'close'].forEach((event) => { 53 | win.on(event, saveState); 54 | }); 55 | }; 56 | 57 | await setBounds(); 58 | 59 | return { 60 | x: windowState.x, 61 | y: windowState.y, 62 | width: windowState.width, 63 | height: windowState.height, 64 | isMaximized: windowState.isMaximized, 65 | track, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/main/util.ts: -------------------------------------------------------------------------------- 1 | /* eslint import/prefer-default-export: off, import/no-mutable-exports: off */ 2 | import { URL } from 'url'; 3 | import path from 'path'; 4 | 5 | export let resolveHtmlPath: (htmlFileName: string) => string; 6 | 7 | if (process.env.NODE_ENV === 'development') { 8 | const port = process.env.PORT || 1212; 9 | resolveHtmlPath = (htmlFileName: string) => { 10 | const url = new URL(`http://localhost:${port}`); 11 | url.pathname = htmlFileName; 12 | return url.href; 13 | }; 14 | } else { 15 | resolveHtmlPath = (htmlFileName: string) => { 16 | return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/App.css: -------------------------------------------------------------------------------- 1 | /* 2 | * @NOTE: Prepend a `~` to css file paths that are in your node_modules 3 | * See https://github.com/webpack-contrib/sass-loader#imports 4 | */ 5 | body { 6 | position: relative; 7 | color: white; 8 | height: 100vh; 9 | background: linear-gradient( 10 | 200.96deg, 11 | #fedc2a -29.09%, 12 | #dd5789 51.77%, 13 | #7a2c9e 129.35% 14 | ); 15 | overflow-y: hidden; 16 | display: flex; 17 | justify-content: center; 18 | align-items: center; 19 | } 20 | 21 | button { 22 | background-color: white; 23 | padding: 10px 20px; 24 | border-radius: 10px; 25 | border: none; 26 | appearance: none; 27 | font-size: 1.3rem; 28 | box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12), 29 | 0px 18px 88px -4px rgba(24, 39, 75, 0.14); 30 | transition: all ease-in 0.1s; 31 | cursor: pointer; 32 | opacity: 0.9; 33 | } 34 | 35 | button:hover { 36 | transform: scale(1.05); 37 | opacity: 1; 38 | } 39 | 40 | li { 41 | list-style: none; 42 | } 43 | 44 | a { 45 | text-decoration: none; 46 | height: fit-content; 47 | width: fit-content; 48 | margin: 10px; 49 | } 50 | 51 | a:hover { 52 | opacity: 1; 53 | text-decoration: none; 54 | } 55 | 56 | .Hello { 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | margin: 20px 0; 61 | } 62 | -------------------------------------------------------------------------------- /src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'; 2 | import Danmaku from './components/Danmaku'; 3 | import './i18n'; 4 | import './app.global.scss'; 5 | 6 | export default function App() { 7 | return ( 8 | 9 | 10 | } /> 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/app.global.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * @NOTE: Prepend a `~` to css file paths that are in your node_modules 3 | * See https://github.com/webpack-contrib/sass-loader#imports 4 | * @import '~@fortawesome/fontawesome-free/css/all.css'; 5 | */ 6 | @import "assets/css/rc_toolTip"; 7 | @import "assets/css/rc_menu"; 8 | @import "assets/css/rc_dropdown"; 9 | @import "assets/css/rc_notification"; 10 | @import "assets/css/main"; 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/assets/css/rc_dropdown.scss: -------------------------------------------------------------------------------- 1 | .rc-dropdown { 2 | position: absolute; 3 | left: -9999px; 4 | top: -9999px; 5 | z-index: 1070; 6 | display: block; 7 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 8 | font-size: 12px; 9 | font-weight: normal; 10 | line-height: 1.5; 11 | } 12 | .rc-dropdown-hidden { 13 | display: none; 14 | } 15 | .rc-dropdown-menu { 16 | outline: none; 17 | position: relative; 18 | list-style-type: none; 19 | padding: 0; 20 | margin: 2px 0 2px; 21 | text-align: left; 22 | background-color: #fff; 23 | border-radius: 3px; 24 | box-shadow: 0 1px 5px #ccc; 25 | background-clip: padding-box; 26 | } 27 | .rc-dropdown-menu > li { 28 | margin: 0; 29 | padding: 0; 30 | } 31 | .rc-dropdown-menu:before { 32 | content: ""; 33 | position: absolute; 34 | top: -4px; 35 | left: 0; 36 | width: 100%; 37 | height: 4px; 38 | background: #ffffff; 39 | background: rgba(255, 255, 255, 0.01); 40 | } 41 | .rc-dropdown-menu > .rc-dropdown-menu-item { 42 | position: relative; 43 | display: block; 44 | padding: 7px 10px; 45 | clear: both; 46 | font-size: 12px; 47 | font-weight: normal; 48 | color: #666666; 49 | white-space: nowrap; 50 | cursor: pointer; 51 | } 52 | .rc-dropdown-menu > .rc-dropdown-menu-item:hover, 53 | .rc-dropdown-menu > .rc-dropdown-menu-item-active, 54 | .rc-dropdown-menu > .rc-dropdown-menu-item-selected { 55 | background-color: #ebfaff; 56 | } 57 | .rc-dropdown-menu > .rc-dropdown-menu-item-selected { 58 | position: relative; 59 | } 60 | 61 | .rc-dropdown-menu > .rc-dropdown-menu-item-disabled { 62 | color: #ccc; 63 | cursor: not-allowed; 64 | pointer-events: none; 65 | } 66 | .rc-dropdown-menu > .rc-dropdown-menu-item-disabled:hover { 67 | color: #ccc; 68 | background-color: #fff; 69 | cursor: not-allowed; 70 | } 71 | .rc-dropdown-menu > .rc-dropdown-menu-item:last-child { 72 | border-bottom-left-radius: 3px; 73 | border-bottom-right-radius: 3px; 74 | } 75 | .rc-dropdown-menu > .rc-dropdown-menu-item:first-child { 76 | border-top-left-radius: 3px; 77 | border-top-right-radius: 3px; 78 | } 79 | .rc-dropdown-menu > .rc-dropdown-menu-item-divider { 80 | height: 1px; 81 | margin: 1px 0; 82 | overflow: hidden; 83 | background-color: #e5e5e5; 84 | line-height: 0; 85 | } 86 | .rc-dropdown-slide-up-enter, 87 | .rc-dropdown-slide-up-appear { 88 | animation-duration: 0.3s; 89 | animation-fill-mode: both; 90 | transform-origin: 0 0; 91 | display: block !important; 92 | opacity: 0; 93 | animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); 94 | animation-play-state: paused; 95 | } 96 | .rc-dropdown-slide-up-leave { 97 | animation-duration: 0.3s; 98 | animation-fill-mode: both; 99 | transform-origin: 0 0; 100 | display: block !important; 101 | opacity: 1; 102 | animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); 103 | animation-play-state: paused; 104 | } 105 | .rc-dropdown-slide-up-enter.rc-dropdown-slide-up-enter-active.rc-dropdown-placement-bottomLeft, 106 | .rc-dropdown-slide-up-appear.rc-dropdown-slide-up-appear-active.rc-dropdown-placement-bottomLeft, 107 | .rc-dropdown-slide-up-enter.rc-dropdown-slide-up-enter-active.rc-dropdown-placement-bottomCenter, 108 | .rc-dropdown-slide-up-appear.rc-dropdown-slide-up-appear-active.rc-dropdown-placement-bottomCenter, 109 | .rc-dropdown-slide-up-enter.rc-dropdown-slide-up-enter-active.rc-dropdown-placement-bottomRight, 110 | .rc-dropdown-slide-up-appear.rc-dropdown-slide-up-appear-active.rc-dropdown-placement-bottomRight { 111 | animation-name: rcDropdownSlideUpIn; 112 | animation-play-state: running; 113 | } 114 | .rc-dropdown-slide-up-enter.rc-dropdown-slide-up-enter-active.rc-dropdown-placement-topLeft, 115 | .rc-dropdown-slide-up-appear.rc-dropdown-slide-up-appear-active.rc-dropdown-placement-topLeft, 116 | .rc-dropdown-slide-up-enter.rc-dropdown-slide-up-enter-active.rc-dropdown-placement-topCenter, 117 | .rc-dropdown-slide-up-appear.rc-dropdown-slide-up-appear-active.rc-dropdown-placement-topCenter, 118 | .rc-dropdown-slide-up-enter.rc-dropdown-slide-up-enter-active.rc-dropdown-placement-topRight, 119 | .rc-dropdown-slide-up-appear.rc-dropdown-slide-up-appear-active.rc-dropdown-placement-topRight { 120 | animation-name: rcDropdownSlideDownIn; 121 | animation-play-state: running; 122 | } 123 | .rc-dropdown-slide-up-leave.rc-dropdown-slide-up-leave-active.rc-dropdown-placement-bottomLeft, 124 | .rc-dropdown-slide-up-leave.rc-dropdown-slide-up-leave-active.rc-dropdown-placement-bottomCenter, 125 | .rc-dropdown-slide-up-leave.rc-dropdown-slide-up-leave-active.rc-dropdown-placement-bottomRight { 126 | animation-name: rcDropdownSlideUpOut; 127 | animation-play-state: running; 128 | } 129 | .rc-dropdown-slide-up-leave.rc-dropdown-slide-up-leave-active.rc-dropdown-placement-topLeft, 130 | .rc-dropdown-slide-up-leave.rc-dropdown-slide-up-leave-active.rc-dropdown-placement-topCenter, 131 | .rc-dropdown-slide-up-leave.rc-dropdown-slide-up-leave-active.rc-dropdown-placement-topRight { 132 | animation-name: rcDropdownSlideDownOut; 133 | animation-play-state: running; 134 | } 135 | @keyframes rcDropdownSlideUpIn { 136 | 0% { 137 | opacity: 0; 138 | transform-origin: 0% 0%; 139 | transform: scaleY(0); 140 | } 141 | 100% { 142 | opacity: 1; 143 | transform-origin: 0% 0%; 144 | transform: scaleY(1); 145 | } 146 | } 147 | @keyframes rcDropdownSlideUpOut { 148 | 0% { 149 | opacity: 1; 150 | transform-origin: 0% 0%; 151 | transform: scaleY(1); 152 | } 153 | 100% { 154 | opacity: 0; 155 | transform-origin: 0% 0%; 156 | transform: scaleY(0); 157 | } 158 | } 159 | @keyframes rcDropdownSlideDownIn { 160 | 0% { 161 | opacity: 0; 162 | transform-origin: 0% 100%; 163 | transform: scaleY(0); 164 | } 165 | 100% { 166 | opacity: 1; 167 | transform-origin: 0% 100%; 168 | transform: scaleY(1); 169 | } 170 | } 171 | @keyframes rcDropdownSlideDownOut { 172 | 0% { 173 | opacity: 1; 174 | transform-origin: 0% 100%; 175 | transform: scaleY(1); 176 | } 177 | 100% { 178 | opacity: 0; 179 | transform-origin: 0% 100%; 180 | transform: scaleY(0); 181 | } 182 | } 183 | .rc-dropdown-arrow { 184 | position: absolute; 185 | border-width: 4px; 186 | border-color: transparent; 187 | box-shadow: 0 1px 5px #ccc; 188 | border-style: solid; 189 | transform: rotate(45deg); 190 | } 191 | .rc-dropdown-show-arrow.rc-dropdown-placement-top, 192 | .rc-dropdown-show-arrow.rc-dropdown-placement-topLeft, 193 | .rc-dropdown-show-arrow.rc-dropdown-placement-topRight { 194 | padding-bottom: 6px; 195 | } 196 | .rc-dropdown-show-arrow.rc-dropdown-placement-bottom, 197 | .rc-dropdown-show-arrow.rc-dropdown-placement-bottomLeft, 198 | .rc-dropdown-show-arrow.rc-dropdown-placement-bottomRight { 199 | padding-top: 6px; 200 | } 201 | .rc-dropdown-placement-top .rc-dropdown-arrow, 202 | .rc-dropdown-placement-topLeft .rc-dropdown-arrow, 203 | .rc-dropdown-placement-topRight .rc-dropdown-arrow { 204 | bottom: 4px; 205 | border-top-color: white; 206 | } 207 | .rc-dropdown-placement-top .rc-dropdown-arrow { 208 | left: 50%; 209 | } 210 | .rc-dropdown-placement-topLeft .rc-dropdown-arrow { 211 | left: 15%; 212 | } 213 | .rc-dropdown-placement-topRight .rc-dropdown-arrow { 214 | right: 15%; 215 | } 216 | .rc-dropdown-placement-bottom .rc-dropdown-arrow, 217 | .rc-dropdown-placement-bottomLeft .rc-dropdown-arrow, 218 | .rc-dropdown-placement-bottomRight .rc-dropdown-arrow { 219 | top: 4px; 220 | border-bottom-color: white; 221 | } 222 | .rc-dropdown-placement-bottom .rc-dropdown-arrow { 223 | left: 50%; 224 | } 225 | .rc-dropdown-placement-bottomLeft .rc-dropdown-arrow { 226 | left: 15%; 227 | } 228 | .rc-dropdown-placement-bottomRight .rc-dropdown-arrow { 229 | right: 15%; 230 | } 231 | -------------------------------------------------------------------------------- /src/renderer/assets/css/rc_notification.scss: -------------------------------------------------------------------------------- 1 | .rc-notification { 2 | position: fixed; 3 | z-index: 1000; 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | font-size: 14px; 8 | line-height: 1.5715; 9 | list-style: none; 10 | top: 8px; 11 | left: 0; 12 | width: 100%; 13 | pointer-events: none; 14 | .rc-notification-notice { 15 | padding: 8px; 16 | text-align: center; 17 | &.warning { 18 | .rc-notification-notice-content { 19 | color: #e6a23c; 20 | background: #fdf6ec; 21 | border-color: #faecd8; 22 | } 23 | } 24 | } 25 | .rc-notification-notice-content { 26 | display: inline-block; 27 | padding: 5px 16px; 28 | background: #fff; 29 | color: #000000d9; 30 | box-shadow: 0 3px 6px -4px #0000001f, 0 6px 16px #00000014, 0 9px 28px 8px #0000000d; 31 | border-radius: 3px 3px; 32 | pointer-events: all; 33 | } 34 | .rc-notification-notice-closable { 35 | padding-right: 20px; 36 | } 37 | .rc-notification-notice-close { 38 | position: absolute; 39 | right: 5px; 40 | top: 3px; 41 | color: #000; 42 | cursor: pointer; 43 | outline: none; 44 | font-size: 16px; 45 | font-weight: 700; 46 | line-height: 1; 47 | text-shadow: 0 1px 0 #fff; 48 | filter: alpha(opacity=20); 49 | opacity: .2; 50 | text-decoration: none; 51 | } 52 | .rc-notification-notice-close-x:after { 53 | content: '×'; 54 | } 55 | .rc-notification-notice-close:hover { 56 | opacity: 1; 57 | filter: alpha(opacity=100); 58 | text-decoration: none; 59 | } 60 | .rc-notification-fade-appear, 61 | .rc-notification-fade-enter { 62 | opacity: 0; 63 | animation-duration: 0.3s; 64 | animation-fill-mode: both; 65 | animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); 66 | animation-play-state: paused; 67 | } 68 | .rc-notification-fade-leave { 69 | animation-duration: 0.3s; 70 | animation-fill-mode: both; 71 | animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); 72 | animation-play-state: paused; 73 | } 74 | .rc-notification-fade-appear.rc-notification-fade-appear-active, 75 | .rc-notification-fade-enter.rc-notification-fade-enter-active { 76 | animation-name: rcNotificationFadeIn; 77 | animation-play-state: running; 78 | } 79 | .rc-notification-fade-leave.rc-notification-fade-leave-active { 80 | animation-name: rcDialogFadeOut; 81 | animation-play-state: running; 82 | } 83 | @keyframes rcNotificationFadeIn { 84 | 0% { 85 | opacity: 0; 86 | } 87 | 100% { 88 | opacity: 1; 89 | } 90 | } 91 | @keyframes rcDialogFadeOut { 92 | 0% { 93 | opacity: 1; 94 | } 95 | 100% { 96 | opacity: 0; 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/renderer/assets/css/rc_toolTip.scss: -------------------------------------------------------------------------------- 1 | /*@import 'rc-tooltip/assets/bootstrap.css';*/ 2 | .rc-tooltip.rc-tooltip-zoom-enter, 3 | .rc-tooltip.rc-tooltip-zoom-leave, 4 | .rc-tooltip.rc-tooltip-fade-leave, 5 | .rc-tooltip.rc-tooltip-fade-leave{ 6 | display: block; 7 | } 8 | .rc-tooltip-zoom-enter, 9 | .rc-tooltip-zoom-appear { 10 | opacity: 0; 11 | animation-duration: 0.3s; 12 | animation-fill-mode: both; 13 | animation-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1.28); 14 | animation-play-state: paused; 15 | } 16 | .rc-tooltip-zoom-leave { 17 | animation-duration: 0.3s; 18 | animation-fill-mode: both; 19 | animation-timing-function: cubic-bezier(0.6, -0.3, 0.74, 0.05); 20 | animation-play-state: paused; 21 | } 22 | .rc-tooltip-zoom-enter.rc-tooltip-zoom-enter-active, 23 | .rc-tooltip-zoom-appear.rc-tooltip-zoom-appear-active { 24 | animation-name: rcToolTipZoomIn; 25 | animation-play-state: running; 26 | } 27 | .rc-tooltip-zoom-leave.rc-tooltip-zoom-leave-active { 28 | animation-name: rcToolTipZoomOut; 29 | animation-play-state: running; 30 | } 31 | @keyframes rcToolTipZoomIn { 32 | 0% { 33 | opacity: 0; 34 | transform-origin: 50% 50%; 35 | transform: scale(0, 0); 36 | } 37 | 100% { 38 | opacity: 1; 39 | transform-origin: 50% 50%; 40 | transform: scale(1, 1); 41 | } 42 | } 43 | @keyframes rcToolTipZoomOut { 44 | 0% { 45 | opacity: 1; 46 | transform-origin: 50% 50%; 47 | transform: scale(1, 1); 48 | } 49 | 100% { 50 | opacity: 0; 51 | transform-origin: 50% 50%; 52 | transform: scale(0, 0); 53 | } 54 | } 55 | 56 | .rc-tooltip-fade-enter, 57 | .rc-tooltip-fade-appear { 58 | opacity: 0; 59 | animation-duration: 0.3s; 60 | animation-fill-mode: both; 61 | animation-play-state: paused; 62 | } 63 | .rc-tooltip-fade-leave { 64 | animation-duration: 0.3s; 65 | animation-fill-mode: both; 66 | animation-play-state: paused; 67 | } 68 | .rc-tooltip-fade-enter.rc-tooltip-fade-enter-active, 69 | .rc-tooltip-fade-appear.rc-tooltip-fade-appear-active { 70 | animation-name: fadeInDown; 71 | animation-play-state: running; 72 | } 73 | .rc-tooltip-fade-leave.rc-tooltip-fade-leave-active { 74 | animation-name: fadeOutUp; 75 | animation-play-state: running; 76 | } 77 | @keyframes fadeInDown { 78 | from { 79 | opacity: 0; 80 | transform: translate3d(0, -100%, 0); 81 | } 82 | to { 83 | opacity: 1; 84 | transform: translate3d(0, 0, 0); 85 | } 86 | } 87 | .fadeInDown { 88 | animation-name: fadeInDown; 89 | } 90 | @keyframes fadeOutUp { 91 | from { 92 | opacity: 1; 93 | } 94 | to { 95 | opacity: 0; 96 | transform: translate3d(0, -100%, 0); 97 | } 98 | } 99 | .fadeOutUp { 100 | animation-name: fadeOutUp; 101 | } 102 | 103 | .rc-tooltip { 104 | position: absolute; 105 | z-index: 1070; 106 | display: block; 107 | visibility: visible; 108 | font-size: 12px; 109 | line-height: 1.5; 110 | opacity: 0.9; 111 | } 112 | .rc-tooltip-hidden { 113 | display: none; 114 | } 115 | .rc-tooltip-placement-top, 116 | .rc-tooltip-placement-topLeft, 117 | .rc-tooltip-placement-topRight { 118 | padding: 5px 0 9px 0; 119 | } 120 | .rc-tooltip-placement-right, 121 | .rc-tooltip-placement-rightTop, 122 | .rc-tooltip-placement-rightBottom { 123 | padding: 0 5px 0 9px; 124 | } 125 | .rc-tooltip-placement-bottom, 126 | .rc-tooltip-placement-bottomLeft, 127 | .rc-tooltip-placement-bottomRight { 128 | padding: 9px 0 5px 0; 129 | } 130 | .rc-tooltip-placement-left, 131 | .rc-tooltip-placement-leftTop, 132 | .rc-tooltip-placement-leftBottom { 133 | padding: 0 9px 0 5px; 134 | } 135 | .rc-tooltip-inner { 136 | padding: 8px 10px; 137 | color: #666; 138 | text-align: left; 139 | text-decoration: none; 140 | min-height: 34px; 141 | background: #fff; 142 | border-radius: 8px; 143 | -webkit-box-shadow: 0 6px 12px 0 rgba(106,115,133,.22); 144 | box-shadow: 0 6px 12px 0 rgba(106,115,133,.22); 145 | } 146 | .rc-tooltip-arrow { 147 | position: absolute; 148 | width: 0; 149 | height: 0; 150 | border-color: transparent; 151 | border-style: solid; 152 | } 153 | .rc-tooltip-placement-top .rc-tooltip-arrow, 154 | .rc-tooltip-placement-topLeft .rc-tooltip-arrow, 155 | .rc-tooltip-placement-topRight .rc-tooltip-arrow { 156 | bottom: 4px; 157 | margin-left: -5px; 158 | border-width: 5px 5px 0; 159 | border-top-color: #fff; 160 | } 161 | .rc-tooltip-placement-top .rc-tooltip-arrow { 162 | left: 50%; 163 | } 164 | .rc-tooltip-placement-topLeft .rc-tooltip-arrow { 165 | left: 15%; 166 | } 167 | .rc-tooltip-placement-topRight .rc-tooltip-arrow { 168 | right: 15%; 169 | } 170 | .rc-tooltip-placement-right .rc-tooltip-arrow, 171 | .rc-tooltip-placement-rightTop .rc-tooltip-arrow, 172 | .rc-tooltip-placement-rightBottom .rc-tooltip-arrow { 173 | left: 4px; 174 | margin-top: -5px; 175 | border-width: 5px 5px 5px 0; 176 | border-right-color: #fff; 177 | } 178 | .rc-tooltip-placement-right .rc-tooltip-arrow { 179 | top: 50%; 180 | } 181 | .rc-tooltip-placement-rightTop .rc-tooltip-arrow { 182 | top: 15%; 183 | margin-top: 0; 184 | } 185 | .rc-tooltip-placement-rightBottom .rc-tooltip-arrow { 186 | bottom: 15%; 187 | } 188 | .rc-tooltip-placement-left .rc-tooltip-arrow, 189 | .rc-tooltip-placement-leftTop .rc-tooltip-arrow, 190 | .rc-tooltip-placement-leftBottom .rc-tooltip-arrow { 191 | right: 4px; 192 | margin-top: -5px; 193 | border-width: 5px 0 5px 5px; 194 | border-left-color: #fff; 195 | } 196 | .rc-tooltip-placement-left .rc-tooltip-arrow { 197 | top: 50%; 198 | } 199 | .rc-tooltip-placement-leftTop .rc-tooltip-arrow { 200 | top: 15%; 201 | margin-top: 0; 202 | } 203 | .rc-tooltip-placement-leftBottom .rc-tooltip-arrow { 204 | bottom: 15%; 205 | } 206 | .rc-tooltip-placement-bottom .rc-tooltip-arrow, 207 | .rc-tooltip-placement-bottomLeft .rc-tooltip-arrow, 208 | .rc-tooltip-placement-bottomRight .rc-tooltip-arrow { 209 | top: 4px; 210 | margin-left: -5px; 211 | border-width: 0 5px 5px; 212 | border-bottom-color: #fff; 213 | } 214 | .rc-tooltip-placement-bottom .rc-tooltip-arrow { 215 | left: 50%; 216 | } 217 | .rc-tooltip-placement-bottomLeft .rc-tooltip-arrow { 218 | left: 15%; 219 | } 220 | .rc-tooltip-placement-bottomRight .rc-tooltip-arrow { 221 | right: 15%; 222 | } 223 | -------------------------------------------------------------------------------- /src/renderer/assets/fonts/lolita.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/src/renderer/assets/fonts/lolita.ttf -------------------------------------------------------------------------------- /src/renderer/bilive/@types/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 lzghzr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/renderer/bilive/@types/danmakuFormatted.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | /* eslint-disable @typescript-eslint/class-name-casing */ 3 | 4 | interface Connecting { 5 | cmd: string; 6 | } 7 | 8 | interface DanmakuMsg { 9 | cmd: string; 10 | username: string; 11 | userID: number; 12 | isAdmin: boolean; 13 | isVip: boolean; 14 | isVipM: boolean; 15 | isVipY: boolean; 16 | guardLevel: number; 17 | userLevel: number; 18 | face?: string; 19 | fanLv: number; 20 | fanName: string; 21 | liveUp: string; 22 | liveRoomID: number | string; 23 | content: string; 24 | repeat: number; 25 | } 26 | 27 | interface DanmakuGift { 28 | cmd: string; 29 | username: string; 30 | userID: number; 31 | face: string; 32 | giftName: string; 33 | giftCount: number; 34 | giftAction: '赠送' | '投喂'; 35 | coinType: 'gold' | 'silver'; 36 | totalCoin: number; 37 | price: number; 38 | giftId: number; 39 | batchComboId?: string; 40 | superGiftNum?: number; 41 | superBatchGiftNum?: number; 42 | } 43 | 44 | interface GiftSend { 45 | cmd: string; 46 | username: string; 47 | userID: number; 48 | comboNum: number; 49 | giftName: string; 50 | giftId: number; 51 | action: '赠送' | '投喂'; 52 | comboId: string; 53 | batchComboId: string; 54 | comboStayTime: number; 55 | } 56 | 57 | interface GuardBuyMsg { 58 | cmd: string; 59 | username: string; 60 | userID: number; 61 | guardLevel: number; 62 | giftName: string; 63 | giftCount: number; 64 | } 65 | 66 | interface MsgWelcomeGuard { 67 | cmd: string; 68 | username: string; 69 | userID: number; 70 | guardLevel: number; 71 | } 72 | 73 | interface MsgWelcome { 74 | cmd: string; 75 | username: string; 76 | userID: number; 77 | isAdmin: boolean; 78 | isVip: boolean; 79 | isVipM: boolean; 80 | isVipY: boolean; 81 | } 82 | 83 | interface MsgInterActWordMsg { 84 | cmd: string; 85 | msgType: number; 86 | username: string; 87 | userID: number; 88 | } 89 | 90 | interface MsgRoomBlockMsg { 91 | cmd: string; 92 | username: string; 93 | userID: number; 94 | } 95 | 96 | interface WarningMsg { 97 | cmd: string; 98 | msg: string; 99 | } 100 | 101 | interface CutOffMsg { 102 | cmd: string; 103 | msg: string; 104 | } 105 | 106 | type GiftBubbleMsg = Partial; 107 | 108 | interface UnknownMsg { 109 | cmd: string; 110 | content: string; 111 | } 112 | 113 | type DanmakuDataFormatted = 114 | | Connecting 115 | | DanmakuMsg 116 | | POPULAR 117 | | WATCHED_CHANGE 118 | | GiftBubbleMsg 119 | | GuardBuyMsg 120 | | MsgWelcomeGuard 121 | | MsgWelcome 122 | | MsgInterActWordMsg 123 | | MsgRoomBlockMsg 124 | | WarningMsg 125 | | CutOffMsg 126 | | UnknownMsg; 127 | 128 | type GiftRaw = { 129 | id: number; 130 | price: number; 131 | name: string; 132 | desc: string; 133 | coin_type: 'gold' | 'silver'; 134 | frame_animation: string; 135 | img_basic: string; 136 | img_dynamic: string; 137 | gif: string; 138 | rights: string; 139 | webp: string; 140 | }; 141 | 142 | type ConfigStateType = { 143 | // 客户端版本号 144 | version: string; 145 | // 最新版本 146 | latestVersion: string; 147 | // 语言 148 | languageCode: string; 149 | // 窗口置于顶层 150 | setAlwaysOnTop: 0 | 1; 151 | // 鼠标穿透 152 | ignoreMouse: 0 | 1; 153 | // 房间号 154 | roomid: number; 155 | // 房间短号 156 | shortid: number; 157 | // 显示头像 158 | showAvatar: number; 159 | // 头像大小 160 | avatarSize: number; 161 | // 显示粉丝头衔 162 | showFanLabel: 0 | 1; 163 | // 显示用户UL等级 164 | showLvLabel: 0 | 1; 165 | // 显示姥爷 166 | showVip: 0 | 1; 167 | // 背景颜色 0白色 1黑色 168 | backgroundColor: 0 | 1; 169 | // 背景透明度 170 | backgroundOpacity: number; 171 | // 弹幕文字字体 172 | fontFamily: string; 173 | // 弹幕文字字号缩放 174 | fontSize: number; 175 | // 弹幕文字行高 176 | fontLineHeight: number; 177 | // 弹幕文字上边距 178 | fontMarginTop: number; 179 | // 固定滚动 180 | blockScrollBar: 0 | 1; 181 | // 开启语音播放 182 | showVoice: 0 | 1; 183 | // 语音音量 184 | voiceVolume: number; 185 | // 语音播放速度 186 | voiceSpeed: number; 187 | // 是否自动翻译 188 | autoTranslate: 0 | 1; 189 | // 翻译输入语言: (auto)自动检测有时候不准确,所以建议设置指定翻译语言 190 | translateFrom: string; 191 | // 翻译输出语言 192 | translateTo: string; 193 | // 最大消息显示数量 194 | maxMessageCount: number; 195 | // 语音队列任务最大值 196 | taskMaxLength: number; 197 | // 朗读语言 198 | voiceTranslateTo: string; 199 | // 开启全局屏蔽 200 | blockMode: 0 | 1; 201 | // 屏蔽礼物弹幕[0,1] 202 | blockEffectItem0: 0 | 1; 203 | // 屏蔽抽奖弹幕[0,1] 204 | blockEffectItem1: 0 | 1; 205 | // 屏蔽进场信息[0,1](包括进入房间,关注了直播间) 206 | blockEffectItem2: 0 | 1; 207 | // 屏蔽醒目留言[0,1] 208 | blockEffectItem3: 0 | 1; 209 | // 屏蔽冒泡礼物[0,1] 210 | blockEffectItem4: 0 | 1; 211 | // 屏蔽舰长弹幕特效 212 | blockEffectItem5: 0 | 1; 213 | // 屏蔽表情动画 214 | blockEffectItem6: 0 | 1; 215 | // 显示最低金瓜子 216 | blockMinGoldSeed: number; 217 | // 显示最低银瓜子 218 | blockMinSilverSeed: number; 219 | // 屏蔽弹幕列表 220 | blockDanmakuLists: string[]; 221 | // 屏蔽用户列表 222 | blockUserLists: number[]; 223 | // 屏蔽用户等级[0,60] 224 | blockUserLv: number; 225 | // 屏蔽非正式会员 226 | blockUserNotMember: 0 | 1; 227 | // 屏蔽非绑定手机用户 228 | blockUserNotBindPhone: 0 | 1; 229 | // 自动翻译弹幕 230 | showTransition: 0 | 1; 231 | // 显示礼物弹幕列表 232 | showGiftDanmakuList: 0 | 1; 233 | // 礼物弹幕最大数量 234 | maxDanmakuGiftCount: number; 235 | // 礼物弹幕列表高度 236 | danmakuGiftListHeight: number; 237 | }; 238 | 239 | type ConfigStateSliceType = { 240 | config: ConfigStateType; 241 | }; 242 | -------------------------------------------------------------------------------- /src/renderer/components/Base/DragSlider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | type Props = { 4 | value: number; 5 | min?: number; 6 | max?: number; 7 | step?: number; 8 | onChange?: (status: number) => void; 9 | }; 10 | 11 | export default function Slider(props: Props) { 12 | const { min = 0, max = 100, step = 5, onChange } = props; 13 | const value = Number(props.value) || 0; 14 | const [currentValue, setCurrentValue] = useState(value); 15 | 16 | useEffect(() => { 17 | setCurrentValue(value); 18 | }, [value]); 19 | 20 | const rule = Math.floor(((currentValue - min) / (max - min)) * 100); 21 | 22 | return ( 23 | setCurrentValue(Number(e.target.value))} 30 | onMouseUp={() => onChange && onChange(currentValue)} 31 | className="range-input chrome" 32 | style={{ 33 | background: `linear-gradient(to right, rgb(35, 173, 229), rgb(35, 173, 229) ${rule}%, rgb(227, 232, 236) ${rule}%, rgb(227, 232, 236))` 34 | }} 35 | /> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/components/Base/Slider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | type Props = { 4 | value: number; 5 | min?: number; 6 | max?: number; 7 | step?: number; 8 | onChange?: (status: number) => void; 9 | }; 10 | 11 | export default function Slider(props: Props) { 12 | const { min = 0, max = 100, step = 5, onChange } = props; 13 | const value = Number(props.value) || 0; 14 | const [currentValue, setCurrentValue] = useState(value); 15 | 16 | useEffect(() => { 17 | setCurrentValue(value); 18 | }, [value]); 19 | 20 | const rule = Math.floor(((currentValue - min) / (max - min)) * 100); 21 | 22 | return ( 23 | setCurrentValue(Number(e.target.value))} 30 | onMouseUp={() => onChange && onChange(currentValue)} 31 | className="range-input chrome" 32 | style={{ 33 | background: `linear-gradient(to right, rgb(35, 173, 229), rgb(35, 173, 229) ${rule}%, rgb(227, 232, 236) ${rule}%, rgb(227, 232, 236))` 34 | }} 35 | /> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/components/Base/Switch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type SwitchStatus = 0 | 1; 4 | 5 | type Props = { 6 | status: SwitchStatus; 7 | onChange?: (status: SwitchStatus) => void; 8 | disabled?: boolean; 9 | }; 10 | 11 | export default function Switch(props: Props) { 12 | const { status = 0, onChange, disabled = false } = props; 13 | const changeStatus = status === 0 ? 1 : 0; 14 | 15 | const handleClick = () => { 16 | if (disabled) return; 17 | onChange && onChange(changeStatus); 18 | }; 19 | 20 | return ( 21 |
22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/DanmakuControl/CustomStyledPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import StyledDao, { StyledDaoNS } from '../../../dao/StyledDao'; 3 | 4 | // user 样式 5 | const UserWrapperStr = StyledDao.get(StyledDaoNS.UserWrapper); 6 | 7 | // content 样式 8 | const ContentWrapperStr = StyledDao.get(StyledDaoNS.ContentWrapper); 9 | 10 | export default function CustomStyledPanel() { 11 | const { t } = useTranslation(); 12 | 13 | return ( 14 | <> 15 |

{t('CustomStyleTitle')}

16 |
17 | 18 | {t('CustomStyledUserName')} 19 | 20 | 24 | StyledDao.save(StyledDaoNS.UserWrapper, e.target.value) 25 | } 26 | className="link-input t-center v-middle border-box level-input custom-style" 27 | /> 28 |
29 |
30 | 31 | {t('CustomStyledDanmakuContent')} 32 | 33 | 37 | StyledDao.save(StyledDaoNS.ContentWrapper, e.target.value) 38 | } 39 | className="link-input t-center v-middle border-box level-input custom-style" 40 | /> 41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/DanmakuControl/LanguagePanel.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import Menu, { Item as MenuItem } from 'rc-menu'; 3 | import Dropdown from 'rc-dropdown'; 4 | import { ConfigKey } from '../../../reducers/types'; 5 | import { HandleUpdateConfigFunc } from './DanmakuControl'; 6 | import lang from '../../../i18n/locales/lang.json'; 7 | import { useAppSelector } from '../../../store/hooks'; 8 | import { selectConfig } from '../../../store/features/configSlice'; 9 | 10 | interface Props { 11 | configKey: 'languageCode' | 'translateTo' | 'voiceTranslateTo'; 12 | handleUpdateConfig: HandleUpdateConfigFunc; 13 | } 14 | 15 | export default function LanguagePanel(props: Props) { 16 | const { configKey, handleUpdateConfig } = props; 17 | const config = useAppSelector(selectConfig); 18 | const { i18n } = useTranslation(); 19 | 20 | function onSelect({ key }) { 21 | switch (configKey) { 22 | case 'languageCode': 23 | handleUpdateConfig(ConfigKey.languageCode, key); 24 | i18n.changeLanguage(key); 25 | break; 26 | case 'translateTo': 27 | handleUpdateConfig(ConfigKey.translateTo, key); 28 | break; 29 | case 'voiceTranslateTo': 30 | handleUpdateConfig(ConfigKey.voiceTranslateTo, key); 31 | break; 32 | default: 33 | break; 34 | } 35 | } 36 | 37 | const menu = ( 38 | 39 | {lang.map(l => ( 40 | {l.Language} 41 | ))} 42 | 43 | ); 44 | 45 | const currentLang = lang.filter(i => i.code === config[configKey])[0]; 46 | 47 | return ( 48 | 53 | 54 | {currentLang.Language} 55 | 56 | 68 | 69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/DanmakuGiftList/DanmakuGiftList.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useEffect, 4 | useCallback, 5 | useRef, 6 | useImperativeHandle, 7 | forwardRef 8 | } from 'react'; 9 | import { updateConfig } from '../../../actions/config'; 10 | import { ConfigKey } from '../../../reducers/types'; 11 | 12 | const minListHeight = 10; 13 | const maxListHeight = 400; 14 | 15 | type Props = { 16 | showGiftDanmakuList: boolean; 17 | height: ConfigStateType; 18 | maxGiftCount: number; 19 | updateConfig: typeof updateConfig; 20 | }; 21 | 22 | export interface DanmakuGiftListRef { 23 | onMessage: (lists: React.ReactElement[]) => void; 24 | } 25 | 26 | function DanmakuGiftList(props: Props, ref: React.Ref) { 27 | const { showGiftDanmakuList, height, maxGiftCount, updateConfig } = props; 28 | let [direction, setDirection] = useState('down'); 29 | const [state, setState] = useState({ 30 | isDragging: false, 31 | height, 32 | top: 0, 33 | original_height: 0, 34 | original_y: 0, 35 | original_mouse_y: 0 36 | }); 37 | const currentState = useRef(state); 38 | currentState.current = state; 39 | 40 | let [renderDanmakuGiftLists, setRenderDanmakuGiftLists] = useState< 41 | React.ReactElement[] 42 | >([]); 43 | 44 | const onMessage = useCallback(lists => { 45 | renderDanmakuGiftLists = [...renderDanmakuGiftLists, ...lists]; 46 | if (renderDanmakuGiftLists.length > maxGiftCount) { 47 | renderDanmakuGiftLists.splice( 48 | 0, 49 | renderDanmakuGiftLists.length - maxGiftCount 50 | ); 51 | } 52 | setRenderDanmakuGiftLists([...renderDanmakuGiftLists]); 53 | }, []); 54 | 55 | useImperativeHandle( 56 | ref, 57 | () => ({ 58 | onMessage 59 | }), 60 | [onMessage] 61 | ); 62 | 63 | const handleMouseMove = useCallback( 64 | ({ clientY }) => { 65 | if (state.isDragging) { 66 | const height = 67 | state.original_height - (clientY - state.original_mouse_y); 68 | if (height > minListHeight && height < maxListHeight) { 69 | setState(prevState => ({ 70 | ...prevState, 71 | height, 72 | top: state.original_y + (clientY - state.original_mouse_y) 73 | })); 74 | } 75 | } 76 | }, 77 | [state.isDragging] 78 | ); 79 | 80 | const handleMouseUp = useCallback(() => { 81 | document.body.style.setProperty('user-select', 'auto'); 82 | if (state.isDragging) { 83 | setState(prevState => ({ 84 | ...prevState, 85 | isDragging: false 86 | })); 87 | updateConfig({ 88 | k: ConfigKey.danmakuGiftListHeight, 89 | v: currentState.current.height 90 | }); 91 | } 92 | }, [state.isDragging]); 93 | 94 | const handleMouseDown = useCallback(({ clientY }) => { 95 | // 移动时禁止user-select,防止选中文本导致Mouse事件失效 96 | document.body.style.setProperty('user-select', 'none'); 97 | const danmakuGiftContainerEl = document.querySelector( 98 | '.danmakuGiftContainer' 99 | ); 100 | setState(prevState => ({ 101 | ...prevState, 102 | isDragging: true, 103 | original_height: danmakuGiftContainerEl 104 | ? parseFloat( 105 | getComputedStyle(danmakuGiftContainerEl, null) 106 | .getPropertyValue('height') 107 | .replace('px', '') 108 | ) 109 | : 0, 110 | original_y: danmakuGiftContainerEl 111 | ? danmakuGiftContainerEl.getBoundingClientRect().top 112 | : 0, 113 | original_mouse_y: clientY 114 | })); 115 | }, []); 116 | 117 | const handleScroll = () => { 118 | const danmakuGiftContainerEl = document.querySelector( 119 | '.danmakuGiftContainer' 120 | ); 121 | if (!danmakuGiftContainerEl) return; 122 | danmakuGiftContainerEl.addEventListener('mousewheel', e => { 123 | direction = e.deltaY > 0 ? 'down' : 'up'; 124 | setDirection(direction); 125 | }); 126 | }; 127 | 128 | const blockScrollBar = () => { 129 | const danmakuGiftContainerEl = document.querySelector( 130 | '.danmakuGiftContainer' 131 | ); 132 | if (!danmakuGiftContainerEl) return; 133 | const { scrollHeight, scrollTop, clientHeight } = danmakuGiftContainerEl; 134 | if (direction === 'up') return; 135 | // 距离底部1/2后自动定位到底部 136 | if (scrollHeight - (scrollTop + clientHeight) > scrollHeight / 2) return; 137 | danmakuGiftContainerEl.scrollTop = scrollHeight; 138 | }; 139 | 140 | useEffect(() => { 141 | handleScroll(); 142 | }, []); 143 | 144 | useEffect(() => { 145 | window.addEventListener('mousemove', handleMouseMove); 146 | window.addEventListener('mouseup', handleMouseUp); 147 | blockScrollBar(); 148 | return () => { 149 | window.removeEventListener('mousemove', handleMouseMove); 150 | window.removeEventListener('mouseup', handleMouseUp); 151 | }; 152 | }, [handleMouseMove, handleMouseUp, renderDanmakuGiftLists]); 153 | 154 | if (!showGiftDanmakuList) return null; 155 | 156 | return ( 157 |
158 |
159 | 160 |
161 |
165 | {[...renderDanmakuGiftLists]} 166 |
167 |
168 | ); 169 | } 170 | 171 | export default forwardRef(DanmakuGiftList); 172 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/DanmakuList/DanmakuList.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useCallback, 4 | useEffect, 5 | useImperativeHandle, 6 | useRef, 7 | useState 8 | } from 'react'; 9 | 10 | interface DanmakuListProps { 11 | showGiftDanmakuList: boolean; 12 | height: number; 13 | maxMessageCount: number; 14 | } 15 | 16 | export interface DanmakuListRef { 17 | onMessage: (lists: React.ReactElement[]) => void; 18 | clearMessage: () => void; 19 | } 20 | 21 | function DanmakuList(props: DanmakuListProps, ref: React.Ref) { 22 | const { showGiftDanmakuList, height, maxMessageCount } = props; 23 | const maxMessageCountRef = useRef(maxMessageCount); 24 | maxMessageCountRef.current = maxMessageCount; 25 | let [direction, setDirection] = useState('down'); 26 | let [renderDanmakuLists, setRenderDanmakuLists] = useState< 27 | React.ReactElement[] 28 | >([]); 29 | 30 | const onMessage = useCallback(lists => { 31 | renderDanmakuLists = [...renderDanmakuLists, ...lists]; 32 | if (renderDanmakuLists.length > maxMessageCountRef.current) { 33 | renderDanmakuLists.splice(0, renderDanmakuLists.length - maxMessageCountRef.current); 34 | } 35 | setRenderDanmakuLists([...renderDanmakuLists]); 36 | }, []); 37 | 38 | const clearMessage = useCallback(() => { 39 | renderDanmakuLists = []; 40 | setRenderDanmakuLists(renderDanmakuLists); 41 | }, []); 42 | 43 | useImperativeHandle( 44 | ref, 45 | () => ({ 46 | onMessage, 47 | clearMessage 48 | }), 49 | [onMessage, clearMessage] 50 | ); 51 | 52 | const handleScroll = () => { 53 | const chatListEl = document.querySelector('.chat-history-list'); 54 | if (!chatListEl) return; 55 | chatListEl.addEventListener('mousewheel', e => { 56 | direction = e.deltaY > 0 ? 'down' : 'up'; 57 | setDirection(direction); 58 | }); 59 | }; 60 | 61 | useEffect(() => { 62 | handleScroll(); 63 | }, []); 64 | 65 | useEffect(() => { 66 | blockScrollBar(); 67 | }, [renderDanmakuLists]); 68 | 69 | function blockScrollBar() { 70 | const chatListEl = document.querySelector('.chat-history-list'); 71 | if (!chatListEl) return; 72 | const { scrollHeight, scrollTop, clientHeight } = chatListEl; 73 | if (direction === 'up') return; 74 | // 距离底部1/3后自动定位到底部 75 | if (scrollHeight - (scrollTop + clientHeight) > scrollHeight / 3) return; 76 | chatListEl.scrollTop = scrollHeight; 77 | } 78 | 79 | return ( 80 |
88 | {[...renderDanmakuLists]} 89 |
90 | ); 91 | } 92 | 93 | export default forwardRef(DanmakuList); 94 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/GiftBubble/Container.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import GiftBubbleItem from "./GiftBubbleItem"; 3 | import { openLink, userAvatarFilter } from "../../../utils/common"; 4 | import { ComboData } from "./Provider"; 5 | import { useAppSelector } from "../../../store/hooks"; 6 | import { selectDanmaku } from "../../../store/features/danmakuSlice"; 7 | 8 | export interface GiftListsItem { 9 | id: number; 10 | ttl: number; 11 | comboId?: string; 12 | msg: DanmakuGift; 13 | } 14 | 15 | interface GiftContainerProps { 16 | lists: GiftListsItem[]; 17 | comboMap: Map; 18 | giftMap: Map; 19 | } 20 | 21 | const Wrapper = styled.div` 22 | z-index: 1; 23 | `; 24 | 25 | function Multiply(batchComboId: string, num: number) { 26 | if (num < 1) return ; 27 | const arr = num.toString().split(''); 28 | return ( 29 | <> 30 | {arr.map((i, index) => ( 31 | 35 | ))} 36 | 37 | ); 38 | } 39 | 40 | function handleOpenUser(uid: number) { 41 | const url = `https://space.bilibili.com/${ uid }`; 42 | openLink(url); 43 | } 44 | 45 | const GiftContainer = (props: GiftContainerProps) => { 46 | const danmaku = useAppSelector(selectDanmaku); 47 | const { giftMap } = danmaku; 48 | const { lists, comboMap } = props; 49 | 50 | return ( 51 | 52 | { 53 | lists.map((item, index) => { 54 | const { msg } = item; 55 | const face = userAvatarFilter(msg.face); 56 | const giftItem = giftMap.get(msg.giftId) || {}; 57 | const comboData = comboMap.get(msg.batchComboId || "-1"); 58 | const giftCount = comboData.giftCount || comboData.superGiftNum; 59 | const { superBatchGiftNum } = comboData; 60 | const key = `${index}-${item.id}` 61 | 62 | return ( 63 | 64 |
65 |
66 |
72 |
73 |
74 |
75 |
76 |
handleOpenUser(msg.userID)} 79 | style={ { backgroundImage: `url(${ face })` } } 80 | /> 81 |
82 |
83 |
84 | { msg.username } 85 |
86 | { msg.giftAction } 87 | { msg.giftName } 88 |
89 |
90 | {/*
*/ } 91 | {/* */ } 92 | {/* */ } 93 | {/* */ } 94 | {/* */ } 95 | {/*
*/ } 96 | {/* 过期的礼物或没有图片的礼物不显示图片 */ } 97 | { giftItem.gif && ( 98 | 103 | ) } 104 | {/* 不包含连击效果 count = giftCount */ } 105 | {/*
*/ } 106 | {/* */ } 107 | {/* {giftCount && Multiply(giftCount)} */ } 108 | {/*
*/ } 109 | 110 | {/* 包含连击效果: count = giftCount * superBatchGiftNum */ } 111 | { 112 |
113 | {/* */ } 114 | 115 | { Multiply( 116 | msg.batchComboId, 117 | giftCount * superBatchGiftNum 118 | ) } 119 |
120 | } 121 |
122 |
123 |
124 |
125 | 126 | ); 127 | }) 128 | } 129 | 130 | ); 131 | }; 132 | 133 | export default GiftContainer; 134 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/GiftBubble/GiftBubble.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useCallback, 4 | useImperativeHandle, 5 | useRef, 6 | useState 7 | } from 'react'; 8 | import ListProvider from './Provider'; 9 | import GiftBubbleEntity, { GiftBubbleEntityRef } from './GiftBubbleEntity'; 10 | 11 | export interface GiftBubbleRef { 12 | onMessage: (msg: GiftBubbleMsg) => void; 13 | } 14 | 15 | function GiftBubble(props, ref: React.Ref) { 16 | const [lists, setLists] = useState([]); 17 | const giftRef = useRef(null); 18 | 19 | const onMessage = useCallback( 20 | (msg: GiftBubbleMsg) => { 21 | lists.push(msg); 22 | setLists([...lists]); 23 | onGiftBubbleMessage(msg); 24 | }, 25 | [] 26 | ); 27 | 28 | const onGiftBubbleMessage = useCallback((msg: GiftBubbleMsg) => { 29 | giftRef.current.onMessage && giftRef.current.onMessage(msg); 30 | }, []); 31 | 32 | useImperativeHandle( 33 | ref, 34 | () => ({ 35 | onMessage 36 | }), 37 | [onMessage] 38 | ); 39 | 40 | return ( 41 |
42 |
43 | 44 | 45 | 46 |
47 |
48 | ); 49 | } 50 | 51 | export default forwardRef(GiftBubble); 52 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/GiftBubble/GiftBubbleEntity.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; 2 | import { useList } from './Provider'; 3 | import { CmdType } from '../MsgModel'; 4 | 5 | export interface GiftBubbleEntityRef { 6 | onMessage: () => void; 7 | } 8 | 9 | const defaultTtl = 1; 10 | 11 | const ListEntity = (props, ref: React.Ref) => { 12 | const { addItem, updateItem } = useList(); 13 | 14 | const onMessage = useCallback( 15 | (msg: SEND_GIFT | COMBO_SEND | COMBO_END) => { 16 | // console.log('onMessage', msg); 17 | if (msg.cmd === CmdType.SEND_GIFT) { 18 | handleAddItem(msg); 19 | } else if (msg.cmd === CmdType.COMBO_SEND) { 20 | handleUpdateItem(msg); 21 | } 22 | }, 23 | [addItem, updateItem] 24 | ); 25 | 26 | useImperativeHandle( 27 | ref, 28 | () => ({ 29 | onMessage 30 | }), 31 | [onMessage] 32 | ); 33 | 34 | const handleAddItem = (msg: GiftBubbleMsg) => { 35 | const ttl = defaultTtl + msg.comboStayTime; 36 | addItem(msg, ttl); 37 | }; 38 | 39 | // TODO: handleUpdateItem 40 | const handleUpdateItem = (msg: GiftBubbleMsg) => { 41 | const ttl = defaultTtl + msg.comboStayTime; 42 | updateItem(msg, ttl); 43 | }; 44 | 45 | return <> 46 | }; 47 | 48 | export default forwardRef(ListEntity); 49 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/GiftBubble/GiftBubbleItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | import { animated } from 'react-spring'; 4 | import { useList } from './Provider'; 5 | import { DanmakuSuperChat } from '../MsgModel'; 6 | 7 | export interface SuperChatItemProps { 8 | id: number; 9 | ttl: number; 10 | msg: DanmakuSuperChat; 11 | config: ConfigStateType; 12 | } 13 | 14 | const Wrapper = styled(animated.div)` 15 | padding: 16px 16px 16px 0; 16 | margin-bottom: 16px; 17 | width: 288px; 18 | position: relative; 19 | `; 20 | 21 | const GiftBubbleItem = ({ children, id, ttl, style }: SuperChatItemProps) => { 22 | const [timer, setTimer] = useState(ttl); 23 | const { removeItem } = useList(); 24 | const intervalRef = useRef(); 25 | 26 | const active = () => { 27 | if (timer > 0) { 28 | const timerId = setInterval(() => { 29 | setTimer(t => t - 1); 30 | }, 1000); 31 | intervalRef.current = timerId; 32 | } 33 | }; 34 | 35 | const destroy = () => { 36 | clearInterval(intervalRef.current); 37 | if (timer === 1) { 38 | removeItem(id); 39 | } 40 | }; 41 | 42 | useEffect(() => { 43 | // console.log('timer=>', timer) 44 | active(); 45 | return () => destroy(); 46 | }, [id, removeItem, ttl, timer]); 47 | 48 | return {children}; 49 | }; 50 | 51 | export default GiftBubbleItem; 52 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/GiftBubble/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useContext, useState } from 'react'; 2 | import SuperChatContainer, { GiftListsItem } from './Container'; 3 | import { sortBy, unionSet } from '../../../utils/common'; 4 | 5 | const SuperChatContext = React.createContext(null); 6 | 7 | export interface ComboData { 8 | giftCount?: number; 9 | superGiftNum?: number; 10 | superBatchGiftNum?: number; 11 | } 12 | 13 | let id = 1; 14 | const comboMap = new Map(); 15 | // 初始化为0 16 | comboMap.set('-1', 0); 17 | 18 | const updateMap = (comboId: string, msg: DanmakuGift) => { 19 | comboMap.set(comboId, msg); 20 | // if (msg.superGiftNum) { 21 | // comboMap.set(comboId, { superGiftNum: msg.superGiftNum }); 22 | // } else { 23 | // comboMap.set(comboId, { giftCount: msg.giftCount }); 24 | // } 25 | }; 26 | 27 | const ListProvider = ({ children }: { children: React.ReactNode }) => { 28 | const [lists, setLists] = useState([]); 29 | 30 | // FIXME: 大量添加会造成阻塞,速度过快添加不了 31 | const addItem = useCallback( 32 | (msg: DanmakuGift, ttl: number) => { 33 | setLists(lists => { 34 | const comboId = msg.batchComboId; 35 | // combo连击增加 36 | if (comboId) { 37 | updateMap(comboId, msg); 38 | } 39 | let newLists = [ 40 | ...lists, 41 | { 42 | id: id++, 43 | ttl, 44 | comboId, 45 | msg 46 | } 47 | ].sort(sortBy('price', false, 'msg')); 48 | newLists = unionSet(newLists, 'comboId'); 49 | // 最多只显示3个 50 | if (newLists.length > 3) { 51 | removeItem(newLists[0].id); 52 | // newLists.splice(0, 1); 53 | } 54 | return newLists; 55 | }); 56 | }, 57 | [setLists] 58 | ); 59 | 60 | const updateItem = useCallback( 61 | (msg: GiftSend, ttl: number, comboId: string) => { 62 | setLists(lists => { 63 | // const newLists = [ 64 | // ...lists, 65 | // { 66 | // id: id++, 67 | // ttl, 68 | // msg 69 | // } 70 | // ].sort(sortBy('price', false, 'msg')); 71 | // if (newLists.length > 3) { 72 | // removeItem(newLists[0].id); 73 | // // newLists.splice(0, 1); 74 | // } 75 | // return newLists; 76 | // comboMap.set(msg.batchComboId, msg.comboNum); 77 | updateMap(msg.batchComboId, msg); 78 | 79 | lists.map(item => { 80 | if (item.comboId === comboId) { 81 | item.msg.giftCount = msg.comboNum; 82 | return item; 83 | } 84 | }); 85 | console.log('handle update update', msg, lists); 86 | return lists; 87 | }); 88 | }, 89 | [setLists] 90 | ); 91 | 92 | const removeItem = useCallback( 93 | (id: number) => { 94 | setLists(lists => { 95 | const newLists = lists.filter(t => t.id !== id); 96 | return newLists; 97 | }); 98 | }, 99 | [setLists] 100 | ); 101 | 102 | return ( 103 | 110 | 111 | {children} 112 | 113 | ); 114 | }; 115 | 116 | const useList = () => { 117 | return useContext(SuperChatContext); 118 | }; 119 | 120 | export { SuperChatContext, useList }; 121 | export default ListProvider; 122 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/LiveRoomLists/LiveRoomLists.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Tooltip from 'rc-tooltip'; 3 | import { animated, useSpring } from 'react-spring'; 4 | import { useTranslation } from 'react-i18next'; 5 | import LiveRoomDao from '../../../dao/LiveRoomDao'; 6 | import { getLiveRoomInfo, LiveRoom } from '../../../api'; 7 | import { openLink, sortBy } from "../../../utils/common"; 8 | import { useAppSelector } from '../../../store/hooks'; 9 | import { selectConfig } from '../../../store/features/configSlice'; 10 | 11 | interface LiveRoomListsProps { 12 | onChangeRoomID: (e: null, shortId: number) => void; 13 | } 14 | 15 | interface ListsProps extends LiveRoomListsProps { 16 | visible: boolean; 17 | } 18 | 19 | function FadeInRight({ children }) { 20 | const props = useSpring({ 21 | from: { 22 | transform: 'translate3d(100%, 0, 0)', 23 | opacity: 0, 24 | }, 25 | to: { 26 | transform: 'translate3d(0, 0, 0)', 27 | opacity: 1, 28 | }, 29 | }); 30 | return {children}; 31 | } 32 | 33 | function Lists(props: ListsProps) { 34 | const { onChangeRoomID, visible } = props; 35 | const { t } = useTranslation(); 36 | const config = useAppSelector(selectConfig); 37 | const currentRoomID = config.shortid; 38 | const roomListsCache = LiveRoomDao.getLists(); 39 | const [loading, setLoading] = useState(true); 40 | let [liveRoomLists, setLiveRoomLists] = useState([]); 41 | // const currentLiveRoomLists = useRef(liveRoomLists); 42 | 43 | function resetLists() { 44 | setLoading(true); 45 | liveRoomLists = []; 46 | setLiveRoomLists(liveRoomLists); 47 | } 48 | 49 | function handleChangeRoom(shortid: number) { 50 | onChangeRoomID(null, shortid); 51 | } 52 | 53 | function handleOpenRoom(shortid: number) { 54 | const url = `https://live.bilibili.com/${shortid}`; 55 | openLink(url); 56 | } 57 | 58 | function handleDeleteRoom(shortid: number) { 59 | LiveRoomDao.delete(shortid); 60 | setLiveRoomLists((lists) => lists.filter((i) => i.shortid !== shortid)); 61 | } 62 | 63 | async function fetchLiveRoomData() { 64 | for (let i = 0; i < roomListsCache.length; i++) { 65 | const roomData = await getLiveRoomInfo(roomListsCache[i].roomid); 66 | liveRoomLists.push(roomData); 67 | setLiveRoomLists((lists) => [...lists, roomData]); 68 | } 69 | liveRoomLists = liveRoomLists.sort(sortBy('isLive')); 70 | setLiveRoomLists([...liveRoomLists]); 71 | setLoading(false); 72 | } 73 | 74 | useEffect(() => { 75 | if (visible) { 76 | resetLists(); 77 | fetchLiveRoomData(); 78 | } 79 | }, [visible]); 80 | 81 | if (loading) { 82 | return ( 83 |
84 | {t('LiveRoomListsLoading')} 85 |
86 |
87 | Loading( 88 | {liveRoomLists.length}/{roomListsCache.length} 89 | )... 90 |
91 |
92 |
93 | ); 94 | } 95 | return ( 96 |
97 | 98 | {liveRoomLists.map(i => { 99 | return ( 100 |
101 |
handleOpenRoom(i.shortid)} 104 | > 105 |
109 | {i.isLive && } 110 |
111 |
handleChangeRoom(i.shortid)} 114 | > 115 |

119 | {i.uname} 120 |

121 |

122 | {i.title} 123 |

124 |
125 | handleDeleteRoom(i.shortid)} 128 | className="icon-item icon-font icon-error error liveIcon live-icon-item pointer" 129 | /> 130 |
131 | ); 132 | })} 133 | 134 |
135 | ); 136 | } 137 | 138 | function LiveRoomLists(props: LiveRoomListsProps) { 139 | const { onChangeRoomID } = props; 140 | const { t } = useTranslation(); 141 | const config = useAppSelector(selectConfig); 142 | const [listsVisible, setListsVisible] = useState(false); 143 | 144 | return ( 145 | setListsVisible(!listsVisible)} 154 | trigger="click" 155 | overlay={ 156 | 160 | } 161 | > 162 | 167 | 168 | ); 169 | } 170 | 171 | export default LiveRoomLists; 172 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgConnectSuccess.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | export default function MsgConnectSuccess() { 4 | const { t } = useTranslation(); 5 | return
{t('SocketConnectSuccess')}
; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgConnecting.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | export default function MsgConnecting() { 4 | const { t } = useTranslation(); 5 | return
{t('SocketDanmakuConnecting')}
; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgDanmu.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import Tooltip from 'rc-tooltip'; 4 | import MsgVip from './MsgVip'; 5 | import MsgUserAvatar from './MsgUserAvatar'; 6 | import { ConfigKey } from '../../../reducers/types'; 7 | import { currentTranslateToCode, translate } from '../../../utils/translation'; 8 | import { openLink } from '../../../utils/common'; 9 | import voice from '../../../utils/vioce'; 10 | import StyledDao, { StyledDaoNS } from '../../../dao/StyledDao'; 11 | import { useAppDispatch, useAppSelector } from "../../../store/hooks"; 12 | import { selectConfig, updateConfig } from "../../../store/features/configSlice"; 13 | import { useTranslation } from "react-i18next"; 14 | 15 | /** 16 | * userName 样式 17 | * eg: 18 | * const UserWrapper = styled.span` 19 | * text-shadow: 1px 1px 2px #E91E63, 0 0 0.2em #E91E63; 20 | * ` 21 | * */ 22 | const UserWrapperStr = StyledDao.get(StyledDaoNS.UserWrapper); 23 | const UserWrapper = styled.span` 24 | ${UserWrapperStr} 25 | `; 26 | 27 | // content 样式 28 | const ContentWrapperStr = StyledDao.get(StyledDaoNS.ContentWrapper); 29 | const ContentWrapper = styled.span` 30 | ${ContentWrapperStr} 31 | `; 32 | 33 | function MsgDanmu(props: DanmakuMsg) { 34 | const dispatch = useAppDispatch(); 35 | const { t } = useTranslation(); 36 | const config = useAppSelector(selectConfig); 37 | 38 | const [showToolTip, setShowToolTip] = useState(false); 39 | let [translateContent, setTranslateContent] = useState(props.content); 40 | 41 | // 翻译文字 42 | const handleTranslate = () => { 43 | translate(props.content, { 44 | from: 'auto', 45 | to: currentTranslateToCode() 46 | }) 47 | .then(translateObj => { 48 | translateContent = `${translateObj.text}`; 49 | setTranslateContent(translateContent); 50 | }) 51 | .catch((e: any) => { 52 | console.log(e); 53 | translateContent = `${props.content}(${t('TranslateFailed')})`; 54 | setTranslateContent(translateContent); 55 | }) 56 | .finally(() => { 57 | setShowToolTip(false); 58 | }); 59 | }; 60 | 61 | useEffect(() => { 62 | if (config.autoTranslate === 1) { 63 | handleTranslate(); 64 | } 65 | }, []); 66 | 67 | // 屏蔽用户 68 | const handleBlockUser = (uid: number) => { 69 | let blockUserLists = JSON.parse(JSON.stringify(config.blockUserLists)); 70 | blockUserLists.push(uid); 71 | blockUserLists = [...new Set(blockUserLists)]; 72 | dispatch(updateConfig({ k: ConfigKey.blockUserLists, v: blockUserLists })); 73 | setShowToolTip(false); 74 | }; 75 | 76 | // 屏蔽对应弹幕文字 77 | const handleBlockDanmaku = (text: string) => { 78 | let blockDanmakuLists = JSON.parse(JSON.stringify(config.blockDanmakuLists)); 79 | blockDanmakuLists.push(text); 80 | blockDanmakuLists = [...new Set(blockDanmakuLists)]; 81 | dispatch(updateConfig({ k: ConfigKey.blockDanmakuLists, v: blockDanmakuLists })); 82 | setShowToolTip(false); 83 | }; 84 | 85 | const handleReadDanmaku = (uname: string, text: string) => { 86 | voice.resetPush(uname, text); 87 | }; 88 | 89 | const danmakuActionMenu = (uid: number, uname: string, text: string) => { 90 | return ( 91 |
92 | openLink(`https://space.bilibili.com/${uid}`)}>{uname} 93 | {t('DanmakuTranslate')} 94 | handleReadDanmaku(uname, text)}>{t('DanmakuRead')} 95 | handleBlockUser(uid)}>{t('DanmakuBlockUser')} 96 | handleBlockDanmaku(text)}>{t('DanmakuBlockSimilar')} 97 |
98 | ) 99 | } 100 | 101 | return ( 102 |
103 | { 104 | config.showAvatar === 1 && MsgUserAvatar(props.userID, props.face) 105 | } 106 | { 107 | props.isAdmin &&
108 | } 109 | 111 | 112 | { 113 | props.fanLv && config.showFanLabel === 1 && ( 114 |
115 |
20 ? 20 : props.fanLv}`] }> 116 | { props.fanName } 117 | { props.fanLv } 118 |
119 |
120 | ) 121 | } 122 | { 123 | config.showLvLabel === 1 &&
UL {props.userLevel}
124 | } 125 | 126 | 127 | {props.username}: 128 | 129 | 130 | setShowToolTip(v)} 135 | trigger="click" 136 | overlay={danmakuActionMenu(props.userID, props.username, props.content)} 137 | > 138 | 139 | 140 | {translateContent} 141 | {props.repeat > 0 && ({props.repeat})} 142 | 143 | 144 | 145 |
146 | ); 147 | } 148 | 149 | export default memo(MsgDanmu); 150 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgDisconnected.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | export default function MsgDisconnected() { 4 | const { t } = useTranslation(); 5 | return
{t('SocketDisconnected')}
; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgEntity.tsx: -------------------------------------------------------------------------------- 1 | import { animated, useSpring } from 'react-spring'; 2 | import { CmdType } from '../MsgModel'; 3 | import MsgWelcome from './MsgWelcome'; 4 | import MsgDanmu from './MsgDanmu'; 5 | import MsgLive from './MsgLive'; 6 | import MsgSendGift from './MsgSendGift'; 7 | import MsgWelcomeGuard from './MsgWelcomeGuard'; 8 | import MsgInterActWord from './MsgInterActWord'; 9 | import MsgGuardBuy from './MsgGuardBuy'; 10 | import MsgGuardBuySystem from './MsgGuardBuySystem'; 11 | import MsgConnecting from './MsgConnecting'; 12 | import MsgDisconnected from './MsgDisconnected'; 13 | import MsgConnectSuccess from './MsgConnectSuccess'; 14 | import MsgSuperChatCard from './MsgSuperChatCard'; 15 | import MsgRoomBlock from './MsgRoomBlock'; 16 | 17 | function FadeInUp({ children }) { 18 | const props = useSpring({ 19 | from: { 20 | transform: 'translate3d(0, 100%, 0)', 21 | opacity: 0 22 | }, 23 | to: { 24 | transform: 'translate3d(0, 0, 0)', 25 | opacity: 1 26 | } 27 | }); 28 | return {children}; 29 | } 30 | 31 | interface MsgEntityProps extends DanmakuDataFormatted { 32 | cmd: string; 33 | key: string; 34 | showTransition: boolean; 35 | data?: any; 36 | showGift?: boolean; 37 | } 38 | 39 | export default function MsgEntity(props: MsgEntityProps) { 40 | const { cmd, showTransition, showGift = false } = props; 41 | let Msg = null; 42 | switch (cmd) { 43 | case CmdType.DANMU_MSG: 44 | Msg = ; 45 | break; 46 | case CmdType.SEND_GIFT: 47 | if (showGift) { 48 | Msg = ; 49 | } 50 | break; 51 | case CmdType.CONNECTING: 52 | Msg = ; 53 | break; 54 | case CmdType.DISCONNECTED: 55 | Msg = ; 56 | break; 57 | case CmdType.CONNECT_SUCCESS: 58 | Msg = ; 59 | break; 60 | case CmdType.SUPER_CHAT_MESSAGE: 61 | Msg = ; 62 | break; 63 | case CmdType.LIVE: 64 | Msg = ; 65 | break; 66 | case CmdType.WELCOME: 67 | Msg = ; 68 | break; 69 | case CmdType.WELCOME_GUARD: 70 | Msg = ; 71 | break; 72 | case CmdType.INTERACT_WORD: 73 | if (showGift) { 74 | Msg = ; 75 | } 76 | break; 77 | case CmdType.GUARD_BUY: 78 | Msg = [, ]; 79 | break; 80 | case CmdType.ROOM_BLOCK_MSG: 81 | Msg = ; 82 | break; 83 | default: 84 | return null; 85 | } 86 | return showTransition ? {Msg} : Msg; 87 | } 88 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgGuardBuy.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | export default function MsgGuardBuy(props: GuardBuyMsg) { 4 | const { ...msg } = props; 5 | const { t } = useTranslation(); 6 | 7 | return ( 8 |
9 | {msg.username} 10 | 11 | {t('DanmakuGuardBuy')} 12 | {props.giftName} 13 | 14 | 18 | 19 | X {msg.giftCount} 20 | {t('DanmakuGuardBuyTime')} 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgGuardBuySystem.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | export default function MsgGuardBuy(props: GuardBuyMsg) { 4 | const { ...msg } = props; 5 | const { t } = useTranslation(); 6 | 7 | return ( 8 |
9 | 13 |
14 | 15 | 16 | {msg.username} 17 | 18 | 19 | {t('DanmakuGuardBuyInRoom')} 20 | {msg.giftName} 21 | 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgInterActWord.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { useAppSelector } from '../../../store/hooks'; 3 | import { selectConfig } from '../../../store/features/configSlice'; 4 | import { openLink } from '../../../utils/common'; 5 | 6 | function MsgInterActWord(props: MsgInterActWordMsg) { 7 | const { ...msg } = props; 8 | const { t } = useTranslation(); 9 | const config = useAppSelector(selectConfig); 10 | let msgEntity = null; 11 | if (config.blockEffectItem2 === 1) return null; 12 | 13 | // msgType === 1 进入了直播间 14 | if (msg.msgType === 1) { 15 | msgEntity = ( 16 |
17 | openLink(`https://space.bilibili.com/${msg.userID}`)}>{msg.username} 18 | {t('DanmakuWelcomeLiveRoom')} 19 |
20 | ); 21 | } else if (msg.msgType === 2) { 22 | // msgType === 2 关注了直播间 23 | msgEntity = ( 24 |
25 | openLink(`https://space.bilibili.com/${msg.userID}`)}>{msg.username} 26 | {t('DanmakuFollowedLiveRoom')} 27 |
28 | ); 29 | } 30 | 31 | return msgEntity; 32 | } 33 | 34 | export default MsgInterActWord; 35 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgLive.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | 3 | export default function MsgLive() { 4 | const { t } = useTranslation(); 5 | return
{t('DanmakuLive')}
; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgRoomBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { openLink } from '../../../utils/common'; 3 | 4 | export default function MsgRoomBlock(props: MsgRoomBlockMsg) { 5 | const { ...msg } = props; 6 | const { t } = useTranslation(); 7 | 8 | return ( 9 |
10 | {t('DanmakuBlockedUser')} 11 | openLink(`https://space.bilibili.com/${msg.userID}`)}>{msg.username} 12 | {t('DanmakuBlockedText')} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgSendGift.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '../../../store/hooks'; 2 | import { selectDanmaku } from '../../../store/features/danmakuSlice'; 3 | import { selectConfig } from '../../../store/features/configSlice'; 4 | import { openLink } from '../../../utils/common'; 5 | 6 | function MsgSendGift(props: DanmakuGift) { 7 | const { ...msg } = props; 8 | const config = useAppSelector(selectConfig); 9 | const danmaku = useAppSelector(selectDanmaku); 10 | const { giftMap } = danmaku; 11 | if (config.blockEffectItem0 === 1) return null; 12 | if (msg.coinType === 'gold' && msg.price < config.blockMinGoldSeed) 13 | return null; 14 | if (msg.coinType === 'silver' && msg.price < config.blockMinSilverSeed) 15 | return null; 16 | 17 | const giftItem = giftMap.get(msg.giftId); 18 | if (giftItem) { 19 | return ( 20 |
21 | openLink(`https://space.bilibili.com/${msg.userID}`)}>{msg.username}  22 | {msg.giftAction} {msg.giftName}{' '} 23 | x {msg.giftCount} 24 |
25 | ); 26 | } 27 | return null; 28 | } 29 | 30 | export default MsgSendGift; 31 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgSuperChatCard.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, useState } from 'react'; 2 | import { currentTranslateToCode, translate } from '../../../utils/translation'; 3 | import { useTranslation } from "react-i18next"; 4 | import { useAppSelector } from "../../../store/hooks"; 5 | import { selectConfig } from "../../../store/features/configSlice"; 6 | import { openLink } from "../../../utils/common"; 7 | 8 | interface SuperChatCardProps { 9 | msg: SUPER_CHAT_MESSAGE_DATA; 10 | style?: CSSProperties; 11 | } 12 | 13 | function handleOpenUser(uid: number) { 14 | const url = `https://space.bilibili.com/${ uid }`; 15 | openLink(url); 16 | } 17 | 18 | function MsgSuperChatCard(props: SuperChatCardProps) { 19 | const { msg, style } = props; 20 | const { t } = useTranslation(); 21 | const config = useAppSelector(selectConfig); 22 | 23 | let [translateContent, setTranslateContent] = useState(msg.message); 24 | 25 | const handleTranslate = () => { 26 | translate(msg.message, { 27 | from: 'auto', 28 | to: currentTranslateToCode() 29 | }) 30 | .then(translateObj => { 31 | translateContent = `${ msg.message }(${ translateObj.text })`; 32 | setTranslateContent(translateContent); 33 | }) 34 | .catch((e: any) => { 35 | console.log(e); 36 | translateContent = `${ msg.message }(${t('TranslateFailed')})`; 37 | setTranslateContent(translateContent); 38 | }); 39 | }; 40 | 41 | if (config.blockEffectItem3 === 1) return null; 42 | 43 | return ( 44 |
45 |
46 |
54 |
handleOpenUser(msg.uid)}> 56 |
62 |
68 |
69 |
70 |
{msg.user_info.uname}
71 |
72 |
73 | ¥{msg.price} 74 | ({Math.floor(msg.price / 10)}万金瓜子) 75 |
76 |
77 |
78 |
79 |
83 |
84 | {translateContent} 85 | 86 |
87 | { 88 | !!msg.message_trans && ( 89 |
90 | {msg.message_trans} 91 |
92 | ) 93 | } 94 |
100 |
101 |
102 |
103 | ); 104 | } 105 | 106 | export default MsgSuperChatCard; 107 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgUserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { openLink, userAvatarFilter } from '../../../utils/common'; 2 | 3 | const defaultAvatar = 'https://static.hdslb.com/images/member/noface.gif'; 4 | 5 | export default function MsgUserAvatar(uid: number, avatar: string) { 6 | const face = userAvatarFilter(avatar); 7 | return ( 8 | openLink(`https://space.bilibili.com/${uid}`)} 11 | className="pointer user-avatar" 12 | /> 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgVip.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '../../../store/hooks'; 2 | import { selectConfig } from '../../../store/features/configSlice'; 3 | 4 | interface VipProps { 5 | isVip: boolean; 6 | isVipM: boolean; 7 | isVipY: boolean; 8 | } 9 | 10 | function MsgVip(vip: VipProps) { 11 | const config = useAppSelector(selectConfig); 12 | if (config.showVip === 0 || !vip.isVip) return null; 13 | if (vip.isVipY) { 14 | return ; 15 | } 16 | if (vip.isVipM) { 17 | return ; 18 | } 19 | return null; 20 | } 21 | 22 | export default MsgVip; 23 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgWelcome.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import MsgVip from './MsgVip'; 3 | import { useAppSelector } from '../../../store/hooks'; 4 | import { selectConfig } from '../../../store/features/configSlice'; 5 | 6 | function MsgWelcome(props: MsgWelcome) { 7 | const { ...msg } = props; 8 | const { t } = useTranslation(); 9 | const config = useAppSelector(selectConfig); 10 | if (config.blockEffectItem2 === 1) return null; 11 | 12 | return ( 13 |
14 | 15 | {msg.username} 老爷 16 | {t('DanmakuWelcomeLiveRoom')} 17 |
18 | ); 19 | } 20 | 21 | export default MsgWelcome; 22 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgEntity/MsgWelcomeGuard.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from "../../../store/hooks"; 2 | import { selectConfig } from "../../../store/features/configSlice"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | 6 | function MsgWelcomeGuard(props: MsgWelcomeGuard) { 7 | const { ...msg } = props; 8 | const config = useAppSelector(selectConfig); 9 | const { t } = useTranslation(); 10 | if (config.blockEffectItem2 === 1) return null; 11 | 12 | return ( 13 |
14 | 15 | {t('DanmakuWelcome')} 16 | 19 | {msg.username} 20 | {t('DanmakuWelcomeLiveRoom')} 21 | 22 |
23 | ); 24 | } 25 | 26 | export default MsgWelcomeGuard; 27 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/MsgModel.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-case-declarations */ 2 | /* eslint-disable consistent-return */ 3 | 4 | import UserInfoApiTask, { apiTaskConfig } from '../../api'; 5 | import config from '../../config'; 6 | import UserAvatarDao from '../../dao/UserAvatarDao'; 7 | import { sleep } from '../../utils/common'; 8 | 9 | export enum CmdType { 10 | CONNECTING = 'CONNECTING', 11 | DISCONNECTED = 'DISCONNECTED', 12 | CONNECT_SUCCESS = 'CONNECT_SUCCESS', 13 | LIVE = 'LIVE', 14 | POPULAR = 'POPULAR', 15 | DANMU_MSG = 'DANMU_MSG', 16 | SEND_GIFT = 'SEND_GIFT', 17 | SPECIAL_GIFT = 'SPECIAL_GIFT', 18 | COMBO_SEND = 'COMBO_SEND', 19 | COMBO_END = 'COMBO_END', 20 | NOTICE_MSG = 'NOTICE_MSG', 21 | INTERACT_WORD = 'INTERACT_WORD', 22 | WATCHED_CHANGE = 'WATCHED_CHANGE', 23 | ENTRY_EFFECT = 'ENTRY_EFFECT', 24 | ROOM_BLOCK_MSG = 'ROOM_BLOCK_MSG', 25 | WELCOME = 'WELCOME', 26 | WELCOME_GUARD = 'WELCOME_GUARD', 27 | GUARD_BUY = 'GUARD_BUY', 28 | SUPER_CHAT_MESSAGE = 'SUPER_CHAT_MESSAGE', 29 | WARNING = 'WARNING', 30 | CUT_OFF = 'CUT_OFF', 31 | UNKNOWN = 'UNKNOWN', 32 | } 33 | 34 | function assertUnknownCmdType(cmd: any): UnknownMsg { 35 | return { 36 | cmd, 37 | content: `Unknown cmd: ${cmd}` 38 | }; 39 | } 40 | 41 | export async function parseData( 42 | data: DanmakuData 43 | ): Promise { 44 | switch (data.cmd) { 45 | case CmdType.LIVE: 46 | break; 47 | case CmdType.DANMU_MSG: 48 | const userID = data.info['2']['0']; 49 | const danmakuMsg: DanmakuMsg = { 50 | cmd: CmdType.DANMU_MSG, 51 | username: data.info['2']['1'], 52 | userID, 53 | isAdmin: !!data.info['2']['2'], 54 | isVip: !!data.info['2']['3'], 55 | isVipM: !!data.info['2']['3'], 56 | isVipY: !!data.info['2']['4'], 57 | guardLevel: data.info['7'], 58 | content: data.info['1'], 59 | fanLv: data.info['3']['0'], 60 | fanName: data.info['3']['1'], 61 | liveUp: data.info['3']['2'], 62 | liveRoomID: data.info['3']['3'], 63 | userLevel: data.info['4']['0'] || 0, 64 | repeat: 0, 65 | }; 66 | // 从消息中获取 67 | if (data.info["0"]["15"] && data.info["0"]["15"]["user"]) { 68 | const face = data.info["0"]["15"]["user"].base.face 69 | UserAvatarDao.save(userID, face); 70 | danmakuMsg.face = face; 71 | } else if (UserAvatarDao.has(userID)) { 72 | // 从Dao中获取,如果有就直接添加 73 | danmakuMsg.face = UserAvatarDao.get(userID).avatar; 74 | } 75 | // 不用这个了 76 | // else if (config.showAvatar && userID > 1) { 77 | // // 从api获取 78 | // UserInfoApiTask.push(userID); 79 | // // 频繁请求api会导致被ban 80 | // if ( 81 | // UserInfoApiTask.getTaskQueueLength() <= apiTaskConfig.taskMaxLength 82 | // ) { 83 | // await sleep(apiTaskConfig.sleepMs); 84 | // } 85 | // danmakuMsg.face = UserAvatarDao.get(userID).avatar; 86 | // } 87 | return danmakuMsg; 88 | case CmdType.SEND_GIFT: 89 | const danmakuGiftMsg: GiftBubbleMsg = { 90 | cmd: CmdType.SEND_GIFT, 91 | username: data.data.uname, 92 | userID: data.data.uid, 93 | face: data.data.face, 94 | giftName: data.data.giftName, 95 | action: data.data.action, 96 | giftCount: data.data.num, 97 | coinType: data.data.coin_type, 98 | totalCoin: data.data.total_coin, 99 | price: data.data.num * data.data.price, 100 | giftAction: data.data.action, 101 | giftId: data.data.giftId 102 | }; 103 | 104 | if (data.data.batch_combo_id) { 105 | danmakuGiftMsg.batchComboId = data.data.batch_combo_id; 106 | } 107 | if (data.data.super_gift_num) { 108 | danmakuGiftMsg.superGiftNum = data.data.super_gift_num; 109 | danmakuGiftMsg.superBatchGiftNum = data.data.super_batch_gift_num; 110 | danmakuGiftMsg.comboStayTime = data.data.combo_stay_time; 111 | } 112 | return danmakuGiftMsg; 113 | case CmdType.WELCOME: 114 | const welcomeMsg: MsgWelcome = { 115 | cmd: CmdType.WELCOME, 116 | username: data.data.uname, 117 | userID: data.data.uid, 118 | isAdmin: !!data.data.is_admin, 119 | isVip: !!data.data.vip, 120 | isVipM: data.data.vip === 1, 121 | isVipY: data.data.svip === 1 122 | }; 123 | return welcomeMsg; 124 | case CmdType.WELCOME_GUARD: 125 | const welcomeguardMsg: MsgWelcomeGuard = { 126 | cmd: CmdType.WELCOME_GUARD, 127 | username: data.data.username, 128 | }; 129 | return welcomeguardMsg; 130 | case CmdType.INTERACT_WORD: 131 | const interActWordMsg: MsgInterActWordMsg = { 132 | cmd: CmdType.INTERACT_WORD, 133 | msgType: data.data.msg_type, 134 | username: data.data.uname, 135 | userID: data.data.uid, 136 | }; 137 | return interActWordMsg; 138 | case CmdType.GUARD_BUY: 139 | const guardBuyMsg: GuardBuyMsg = { 140 | cmd: CmdType.GUARD_BUY, 141 | username: data.data.username, 142 | userID: data.data.uid, 143 | guardLevel: data.data.guard_level, 144 | giftName: ['', '总督', '提督', '舰长'][data.data.guard_level], 145 | giftCount: data.data.num 146 | }; 147 | return guardBuyMsg; 148 | case CmdType.SUPER_CHAT_MESSAGE: 149 | const superChatMsg: SUPER_CHAT_MESSAGE = { 150 | cmd: CmdType.SUPER_CHAT_MESSAGE, 151 | data: data.data 152 | }; 153 | return superChatMsg; 154 | case CmdType.COMBO_SEND: 155 | const comboSendMsg: GiftBubbleMsg = { 156 | cmd: CmdType.COMBO_SEND, 157 | userID: data.data.uid, 158 | username: data.data.uname, 159 | giftName: data.data.gift_name, 160 | giftId: data.data.gift_id, 161 | comboId: data.data.combo_id, 162 | comboNum: data.data.combo_num, 163 | batchComboId: data.data.batch_combo_id, 164 | action: data.data.action 165 | }; 166 | return comboSendMsg; 167 | case CmdType.POPULAR: 168 | const popularMsg: POPULAR = { 169 | cmd: CmdType.POPULAR, 170 | popular: data.popular 171 | }; 172 | return popularMsg; 173 | case CmdType.WATCHED_CHANGE: 174 | const watchedChangeMsg: WATCHED_CHANGE = { 175 | cmd: CmdType.WATCHED_CHANGE, 176 | data: data.data 177 | }; 178 | return watchedChangeMsg; 179 | // case CmdType.COMBO_END: 180 | // // TODO: 181 | // break; 182 | case CmdType.ROOM_BLOCK_MSG: 183 | const roomBlockMsg: MsgRoomBlockMsg = { 184 | cmd: CmdType.ROOM_BLOCK_MSG, 185 | username: data.data.uname, 186 | userID: data.data.uid, 187 | }; 188 | return roomBlockMsg; 189 | case CmdType.WARNING: 190 | const warningMsg: WarningMsg = { 191 | cmd: CmdType.WARNING, 192 | msg: data.msg, 193 | }; 194 | return warningMsg; 195 | case CmdType.CUT_OFF: 196 | const cutOffMsg: CutOffMsg = { 197 | cmd: CmdType.CUT_OFF, 198 | msg: data.msg, 199 | }; 200 | return cutOffMsg; 201 | default: 202 | return assertUnknownCmdType(data.cmd); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/RankMessageLists/RankMessageLists.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import Tooltip from 'rc-tooltip'; 3 | import { animated, useSpring } from 'react-spring'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { getRankMessageList, RankMessageListsItem } from '../../../api'; 6 | import { useAppSelector } from '../../../store/hooks'; 7 | import { selectConfig } from '../../../store/features/configSlice'; 8 | import { dateFormat, openLink } from '../../../utils/common'; 9 | 10 | interface ListsProps { 11 | visible: boolean; 12 | } 13 | 14 | function FadeInRight({ children }) { 15 | const props = useSpring({ 16 | from: { 17 | transform: 'translate3d(100%, 0, 0)', 18 | opacity: 0, 19 | }, 20 | to: { 21 | transform: 'translate3d(0, 0, 0)', 22 | opacity: 1, 23 | }, 24 | }); 25 | return {children}; 26 | } 27 | 28 | function Lists(props: ListsProps) { 29 | const { visible } = props; 30 | const { t } = useTranslation(); 31 | const config = useAppSelector(selectConfig); 32 | const [loading, setLoading] = useState(true); 33 | const [lists, setLists] = useState([]); 34 | 35 | function resetLists() { 36 | setLoading(true); 37 | setLists([]); 38 | } 39 | 40 | async function fetchRankMessageListData() { 41 | const messageListsData = await getRankMessageList(config.roomid); 42 | setLists(messageListsData); 43 | setLoading(false); 44 | } 45 | 46 | useEffect(() => { 47 | if (visible) { 48 | resetLists(); 49 | fetchRankMessageListData(); 50 | } 51 | }, [visible]); 52 | 53 | if (loading) { 54 | return ( 55 |
56 | {t('RankMessageList')} 57 |
58 |
Loading...
59 |
60 |
61 | ); 62 | } 63 | if (lists.length === 0) { 64 | return ( 65 |
66 | {t('RankMessageList')} 67 |
68 |
69 | ); 70 | } 71 | return ( 72 |
73 | 74 | { 75 | lists.map((i, index) => { 76 | return ( 77 |
78 |
79 | { 80 | index <= 2 81 | ?
82 | :
{index+1}
83 | } 84 |
openLink(`https://space.bilibili.com/${i.uid}`)}> 85 | 86 | {i.face_frame && } 87 |
88 |
89 |

openLink(`https://space.bilibili.com/${i.uid}`)}>{i.uname}

90 |

{dateFormat(new Date(i.send_time * 1000), 'HH:MM:SS')}

91 |
92 |
¥ {i.price}
93 |
94 |
{i.message}
95 |
96 |
97 | ); 98 | }) 99 | } 100 | 101 |
102 | ); 103 | } 104 | 105 | function RankMessageLists() { 106 | const { t } = useTranslation(); 107 | const [listsVisible, setListsVisible] = useState(false); 108 | 109 | return ( 110 | setListsVisible(!listsVisible)} 119 | trigger="click" 120 | overlay={} 121 | > 122 | 127 | 128 | ); 129 | } 130 | 131 | export default RankMessageLists; 132 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/SuperChatPanel/Container.tsx: -------------------------------------------------------------------------------- 1 | import SuperChatItem from './SuperChatItem'; 2 | 3 | const SuperChatContainer = ({ lists }) => { 4 | return ( 5 | <> 6 | {lists.map(item => { 7 | return ; 8 | })} 9 | 10 | ); 11 | }; 12 | 13 | export default SuperChatContainer; 14 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/SuperChatPanel/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useCallback } from 'react'; 2 | import SuperChatContainer from './Container'; 3 | import { sortBy } from '../../../utils/common'; 4 | 5 | const SuperChatContext = React.createContext(null); 6 | 7 | let id = 1; 8 | 9 | const ListProvider = ({ children, removeItemCb }) => { 10 | let [lists, setLists] = useState([]); 11 | 12 | const addItem = useCallback( 13 | (msg: SUPER_CHAT_MESSAGE_DATA, ttl: number) => { 14 | setLists(lists => [ 15 | ...lists, 16 | { 17 | id: id++, 18 | ttl, 19 | msg 20 | } 21 | ].sort(sortBy('price', false, 'msg'))); 22 | }, 23 | [setLists] 24 | ); 25 | 26 | const removeItem = useCallback( 27 | (id: number) => { 28 | setLists(lists => { 29 | const newLists = lists.filter(t => t.id !== id) 30 | removeItemCb(newLists); 31 | return newLists; 32 | }); 33 | }, 34 | [setLists] 35 | ); 36 | 37 | const clear = useCallback( 38 | () => { 39 | setLists([]); 40 | }, 41 | [setLists] 42 | ); 43 | 44 | return ( 45 | 52 | 53 | {children} 54 | 55 | ); 56 | }; 57 | 58 | const useList = () => { 59 | const listHelpers = useContext(SuperChatContext); 60 | return listHelpers; 61 | }; 62 | 63 | export { SuperChatContext, useList }; 64 | export default ListProvider; 65 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/SuperChatPanel/SuperChatEntity.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; 2 | import { useList } from './Provider'; 3 | 4 | export interface SuperChatEntityProps { 5 | onMessage: (msg: SUPER_CHAT_MESSAGE_DATA) => void; 6 | clearMessage: () => void; 7 | } 8 | 9 | const ListEntity = (props, ref: React.Ref) => { 10 | const { addItem, clear } = useList(); 11 | 12 | const onMessage = useCallback( 13 | (msg: SUPER_CHAT_MESSAGE_DATA) => { 14 | handleAddItem(msg); 15 | }, 16 | [addItem] 17 | ); 18 | 19 | const clearMessage = useCallback( 20 | () => { 21 | clear(); 22 | }, 23 | [clear] 24 | ); 25 | 26 | useImperativeHandle( 27 | ref, 28 | () => ({ 29 | onMessage, 30 | clearMessage 31 | }), 32 | [onMessage, clearMessage] 33 | ); 34 | 35 | const handleAddItem = (msg: SUPER_CHAT_MESSAGE_DATA) => { 36 | // 总时长 37 | const ttl = msg.end_time - msg.start_time; 38 | addItem(msg, ttl); 39 | }; 40 | 41 | return <>; 42 | }; 43 | 44 | export default forwardRef(ListEntity); 45 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/SuperChatPanel/SuperChatItem.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react'; 2 | import Tooltip from 'rc-tooltip'; 3 | import { useList } from './Provider'; 4 | import MsgSuperChatCard from '../MsgEntity/MsgSuperChatCard'; 5 | 6 | export interface SuperChatItemProps { 7 | id: number; 8 | ttl: number; 9 | msg: SUPER_CHAT_MESSAGE_DATA; 10 | } 11 | 12 | const SuperChatItem = ({ id, ttl, msg }: SuperChatItemProps) => { 13 | // timer = ttl(总时长) - (end_time - ts)已过期时长 14 | const [timer, setTimer] = useState(ttl - (msg.ts - msg.start_time)); 15 | const { removeItem } = useList(); 16 | const [showDetail, setShowDetail] = useState(false); 17 | const intervalRef = useRef(); 18 | 19 | const active = () => { 20 | if (timer > 0) { 21 | const timerId = setInterval(() => { 22 | setTimer(t => t - 1); 23 | }, 1000); 24 | intervalRef.current = timerId; 25 | } 26 | }; 27 | 28 | const destroy = () => { 29 | clearInterval(intervalRef.current); 30 | if (timer === 1) { 31 | removeItem(id); 32 | } 33 | }; 34 | 35 | useEffect(() => { 36 | active(); 37 | return () => destroy(); 38 | }, [id, removeItem, ttl, timer]); 39 | 40 | const w = Math.floor((timer / ttl) * 100); 41 | 42 | return ( 43 | setShowDetail(!showDetail)} 49 | trigger="click" 50 | overlay={} 51 | > 52 |
56 |
57 |
61 |
65 |
71 |
72 | ¥{msg.price} 73 |
74 |
75 |
76 | 77 | ); 78 | }; 79 | 80 | export default SuperChatItem; 81 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/SuperChatPanel/SuperChatPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | useImperativeHandle, 4 | useCallback, 5 | useRef, 6 | useState 7 | } from 'react'; 8 | import ListProvider from './Provider'; 9 | import SuperChatEntity, { SuperChatEntityProps } from './SuperChatEntity'; 10 | import { SuperChatItemProps } from './SuperChatItem'; 11 | 12 | export interface SuperChatPanelRef { 13 | onScEntityMessage: (msg: SUPER_CHAT_MESSAGE_DATA) => void; 14 | clearMessage: () => void; 15 | } 16 | 17 | function SuperChatPanel(props, ref: React.Ref) { 18 | const [lists, setLists] = useState([]); 19 | const [prevVisible, setPrevVisible] = useState(false); 20 | const [nextVisible, setNextVisible] = useState(false); 21 | const scEntityRef = useRef(null); 22 | const sliderRef = useRef(null); 23 | const offsetWidthValue = sliderRef.current ? sliderRef.current.offsetWidth : 0; 24 | const scrollWidthValue = sliderRef.current ? sliderRef.current.scrollWidth : 0; 25 | 26 | const onMessage = useCallback( 27 | (msg: SUPER_CHAT_MESSAGE) => { 28 | lists.push(msg); 29 | setLists([...lists]); 30 | onScEntityMessage(msg); 31 | showSliderBtn(sliderRef.current.offsetWidth, sliderRef.current.scrollWidth) 32 | }, 33 | [] 34 | ); 35 | 36 | const onClearMessage = useCallback( 37 | () => { 38 | clearMessage() 39 | }, [] 40 | ) 41 | 42 | const onScEntityMessage = useCallback((msg) => { 43 | scEntityRef.current.onMessage && scEntityRef.current.onMessage(msg); 44 | }, []); 45 | 46 | const clearMessage = useCallback(() => { 47 | scEntityRef.current.clearMessage && scEntityRef.current.clearMessage(); 48 | }, []); 49 | 50 | useImperativeHandle( 51 | ref, 52 | () => ({ 53 | onMessage, 54 | onClearMessage 55 | }), 56 | [onMessage, onClearMessage] 57 | ); 58 | 59 | const removeItemCb = (lists: SuperChatItemProps[]) => { 60 | setLists(lists); 61 | showSliderBtn(sliderRef.current.offsetWidth, sliderRef.current.scrollWidth); 62 | }; 63 | 64 | const showSliderBtn = (offsetWidthValue, scrollWidthValue) => { 65 | setPrevVisible(sliderRef.current.scrollLeft > 0); 66 | setNextVisible(sliderRef.current.scrollLeft + offsetWidthValue < scrollWidthValue) 67 | }; 68 | 69 | const handleArrowClick = (position: string) => { 70 | const el = sliderRef.current; 71 | if(position === 'left') { 72 | el.scrollLeft -= offsetWidthValue / 2; 73 | } else { 74 | el.scrollLeft += offsetWidthValue / 2; 75 | } 76 | showSliderBtn(offsetWidthValue, scrollWidthValue); 77 | }; 78 | 79 | return ( 80 |
81 |
82 |
83 | 84 | 85 | 86 |
87 |
handleArrowClick('left')} /> 88 |
handleArrowClick('right')} /> 89 |
90 |
91 | ); 92 | } 93 | 94 | export default forwardRef(SuperChatPanel); 95 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/common/msg-struct.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 各个帧结构所需要的字段,待拓展 3 | */ 4 | 5 | const messageStruct = [ 6 | { 7 | name: 'Header Length', // 帧头 8 | key: 'headerLen', 9 | bytes: 2, // 字节长度 10 | offset: 4, // 偏移量 11 | value: 16 12 | }, 13 | { 14 | name: 'Protocol Version', // 协议版本 15 | key: 'ver', 16 | bytes: 2, 17 | offset: 6, 18 | value: 1 19 | }, 20 | { 21 | name: 'Operation', // 指令 22 | key: 'op', 23 | bytes: 4, 24 | offset: 8, 25 | value: 1 26 | }, 27 | { 28 | name: 'Sequence Id', 29 | key: 'seq', 30 | bytes: 4, 31 | offset: 12, 32 | value: 1 33 | } 34 | ]; 35 | 36 | export default messageStruct; 37 | -------------------------------------------------------------------------------- /src/renderer/components/Danmaku/common/ws-url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取对应的ws-url地址 3 | */ 4 | // let wsUrl = 'wss://ks-live-dmcmt-bj6-pm-01.chat.bilibili.com/sub'; 5 | let wsUrl = 'ws://broadcastlv.chat.bilibili.com:2244/sub'; 6 | 7 | if (window !== undefined) { 8 | const protocol = location.origin.match(/^(.+):\/\//)[1]; 9 | if (protocol === 'https') { 10 | wsUrl = 'wss://broadcastlv.chat.bilibili.com:2245/sub'; 11 | } 12 | } 13 | 14 | export default wsUrl; 15 | -------------------------------------------------------------------------------- /src/renderer/config.ts: -------------------------------------------------------------------------------- 1 | import ConfigDao from './dao/ConfigDao'; 2 | 3 | const config = ConfigDao.get(); 4 | 5 | export function setConfig(configKey: string, value: string | number) { 6 | console.log('configKey', configKey, 'value', value); 7 | config[configKey] = value; 8 | ConfigDao.save(config); 9 | } 10 | 11 | // 直接挂载到全局 12 | global.config = config; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /src/renderer/dao/ConfigDao.ts: -------------------------------------------------------------------------------- 1 | import LiveRoomDao from './LiveRoomDao'; 2 | import pkg from '../../../package.json'; 3 | 4 | const prefixKey = 'config'; 5 | 6 | const resentLiveData = LiveRoomDao.getResent(); 7 | 8 | export const defaultConfig: ConfigStateType = { 9 | version: pkg.version, 10 | latestVersion: pkg.version, 11 | languageCode: 'zhCn', 12 | setAlwaysOnTop: 1, 13 | roomid: resentLiveData.roomid, 14 | shortid: resentLiveData.shortid, 15 | ignoreMouse: 0, 16 | showAvatar: 0, 17 | avatarSize: 24, 18 | showFanLabel: 0, 19 | showLvLabel: 0, 20 | showVip: 0, 21 | backgroundColor: 1, 22 | backgroundOpacity: 0.5, 23 | fontFamily: '', 24 | fontSize: 17, 25 | fontLineHeight: 24, 26 | fontMarginTop: 3, 27 | blockScrollBar: 0, 28 | showVoice: 0, 29 | voiceVolume: 0.3, 30 | voiceSpeed: 1, 31 | autoTranslate: 0, 32 | translateFrom: 'auto', 33 | translateTo: 'ja', 34 | maxMessageCount: 500, 35 | taskMaxLength: 5, 36 | voiceTranslateTo: 'zhCn', 37 | blockMode: 0, 38 | blockEffectItem0: 0, 39 | blockEffectItem1: 0, 40 | blockEffectItem2: 1, 41 | blockEffectItem3: 0, 42 | blockEffectItem4: 0, 43 | blockEffectItem5: 0, 44 | blockEffectItem6: 0, 45 | blockMinGoldSeed: 0, 46 | blockMinSilverSeed: 0, 47 | blockDanmakuLists: [], 48 | blockUserLists: [], 49 | blockUserLv: 0, 50 | blockUserNotMember: 0, 51 | blockUserNotBindPhone: 0, 52 | showTransition: 1, 53 | showGiftDanmakuList: 0, 54 | maxDanmakuGiftCount: 30, 55 | danmakuGiftListHeight: 200, 56 | }; 57 | 58 | export default class ConfigDao { 59 | static get(): ConfigStateType { 60 | const configStr = localStorage.getItem(prefixKey); 61 | if (!configStr) return defaultConfig; 62 | const configData: ConfigStateType = { 63 | ...defaultConfig, 64 | ...JSON.parse(configStr), 65 | }; 66 | configData.version = pkg.version; 67 | // 与最新版config合并 68 | this.save(configData); 69 | return configData; 70 | } 71 | 72 | static save(config: ConfigStateType) { 73 | localStorage.setItem(prefixKey, JSON.stringify(config)); 74 | } 75 | 76 | static reset(): ConfigStateType { 77 | localStorage.removeItem(prefixKey); 78 | this.save(defaultConfig); 79 | return defaultConfig; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/renderer/dao/LiveRoomDao.ts: -------------------------------------------------------------------------------- 1 | import { sortBy, unionSet } from '../utils/common'; 2 | 3 | const prefixKey = 'liveRoomData'; 4 | 5 | export interface LiveRoomData { 6 | // 房间号 7 | roomid: number; 8 | // 房间短号(如果api返回 0, shortid 为 roomid) 9 | shortid: number; 10 | uid: number; 11 | // 上次数据添加时间 12 | lastTime: number; 13 | } 14 | 15 | const defaultLiveRoomData: LiveRoomData = { 16 | roomid: 213, 17 | shortid: 47867, 18 | uid: 0, 19 | lastTime: Date.now() 20 | }; 21 | 22 | export default class LiveRoomDao { 23 | static get(shortid: number): LiveRoomData { 24 | let lists = this.getLists(); 25 | lists = lists.filter(i => i.shortid === shortid); 26 | return lists[0]; 27 | } 28 | 29 | // 获取上一次的直播间 30 | static getResent(): LiveRoomData { 31 | const lists = this.getLists(); 32 | return lists[0]; 33 | } 34 | 35 | // 获取直播间历史记录列表(按时间倒序排列) 36 | static getLists(): LiveRoomData[] { 37 | const liveRoomStr = localStorage.getItem(prefixKey); 38 | if (!liveRoomStr) { 39 | return [defaultLiveRoomData]; 40 | } 41 | // 防止数据为空,为空时返回默认 LiveRoomData[] 42 | const lists: LiveRoomData[] = JSON.parse(liveRoomStr); 43 | if (lists.length === 0) { 44 | return [defaultLiveRoomData]; 45 | } 46 | return lists.sort(sortBy('lastTime', false)); 47 | } 48 | 49 | static save(data: LiveRoomData) { 50 | let lists = this.getLists(); 51 | lists.unshift(data); 52 | lists = unionSet(lists, 'roomid'); 53 | localStorage.setItem(prefixKey, JSON.stringify(lists)); 54 | } 55 | 56 | static delete(shortid: number) { 57 | let lists = this.getLists(); 58 | lists = lists.filter(i => i.shortid !== shortid); 59 | localStorage.setItem(prefixKey, JSON.stringify(lists)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/renderer/dao/StyledDao.ts: -------------------------------------------------------------------------------- 1 | export enum StyledDaoNS { 2 | UserWrapper = 'UserWrapper', 3 | ContentWrapper = 'ContentWrapper' 4 | } 5 | 6 | export default class StyledDao { 7 | static has(nameSpace: StyledDaoNS): boolean { 8 | return localStorage.getItem(nameSpace) !== null; 9 | } 10 | 11 | static get(nameSpace: StyledDaoNS): string { 12 | return localStorage.getItem(nameSpace) || ''; 13 | } 14 | 15 | static save(nameSpace: StyledDaoNS, style: string) { 16 | localStorage.setItem(nameSpace, style); 17 | } 18 | 19 | static clear() { 20 | localStorage.removeItem(StyledDaoNS.UserWrapper); 21 | localStorage.removeItem(StyledDaoNS.ContentWrapper); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/dao/UesrInfoDao.ts: -------------------------------------------------------------------------------- 1 | export enum UserInfoDaoNS { 2 | UserInfoUid = 'UserInfoUid', 3 | UserInfoSession = 'UserInfoSession', 4 | } 5 | 6 | export default class UserInfoDao { 7 | static has(nameSpace: UserInfoDaoNS): boolean { 8 | return localStorage.getItem(nameSpace) !== null; 9 | } 10 | 11 | static get(nameSpace: UserInfoDaoNS): string { 12 | return localStorage.getItem(nameSpace) || ''; 13 | } 14 | 15 | static save(nameSpace: UserInfoDaoNS, style: string) { 16 | localStorage.setItem(nameSpace, style); 17 | } 18 | 19 | static clear() { 20 | localStorage.removeItem(UserInfoDaoNS.UserInfoUid); 21 | localStorage.removeItem(UserInfoDaoNS.UserInfoSession); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/dao/UserAvatarDao.ts: -------------------------------------------------------------------------------- 1 | const defaultAvatar = 'https://static.hdslb.com/images/member/noface.gif'; 2 | // expires: 单位毫秒, 默认10天 3 | const expires = 1000 * 60 * 60 * 24 * 10; 4 | 5 | interface UserAvatar { 6 | uid: number; 7 | avatar: string; 8 | expires: number; 9 | } 10 | 11 | interface UserAvatarData { 12 | [key: number]: UserAvatar 13 | } 14 | 15 | // 放缓存里面更快些 16 | let userAvatarData:UserAvatarData = {} 17 | 18 | export default class UserAvatarDao { 19 | // 初始化 检验expires, 到期自动删除 20 | static init() { 21 | const keyArr = Object.keys(localStorage); 22 | for (let i = 0; i < keyArr.length; i++) { 23 | if (keyArr[i].indexOf(`uid_`) !== -1) { 24 | const uid = Number(keyArr[i].replace('uid_', '')); 25 | const dataStr = localStorage.getItem(keyArr[i]) 26 | if(dataStr) { 27 | const parseData: UserAvatar = JSON.parse(dataStr); 28 | if (Date.now() >= parseData.expires) { 29 | this.delete(uid); 30 | } else { 31 | userAvatarData[uid] = parseData 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | static has(uid: number): boolean { 39 | return userAvatarData.hasOwnProperty(uid); 40 | } 41 | 42 | static get(uid: number): UserAvatar { 43 | const data = userAvatarData[uid]; 44 | if (!data) { 45 | return { 46 | uid, 47 | avatar: defaultAvatar, 48 | expires: Date.now(), 49 | }; 50 | } 51 | return data; 52 | } 53 | 54 | static save(uid: number, avatar: string) { 55 | if(!uid || !avatar) return 56 | 57 | const avatarData: UserAvatar = { 58 | uid, 59 | avatar, 60 | expires: Date.now() + expires 61 | }; 62 | userAvatarData[uid] = avatarData; 63 | localStorage.setItem(`uid_${uid}`, JSON.stringify(avatarData)); 64 | } 65 | 66 | static delete(uid: number) { 67 | delete userAvatarData[uid] 68 | localStorage.removeItem(`uid_${uid}`); 69 | } 70 | } 71 | 72 | UserAvatarDao.init(); 73 | -------------------------------------------------------------------------------- /src/renderer/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | import jaTrans from './locales/ja.json'; 5 | import zhCnTrans from './locales/zh-cn.json'; 6 | import config from '../config'; 7 | 8 | const fallbackLng = 'zhCn'; 9 | const defaultNS = 'translation'; 10 | const lng = config.languageCode || fallbackLng; 11 | 12 | const resources = { 13 | zhCn: { 14 | common: { 15 | ...zhCnTrans 16 | } 17 | }, 18 | ja: { 19 | common: { 20 | ...jaTrans 21 | } 22 | } 23 | }; 24 | 25 | i18n 26 | .use(LanguageDetector) 27 | .use(initReactI18next) 28 | .init({ 29 | resources, 30 | lng, 31 | ns: [defaultNS, 'local'], 32 | defaultNS, 33 | fallbackNS: ['local', 'common'], 34 | keySeparator: false, 35 | interpolation: { 36 | escapeValue: false, 37 | formatSeparator: ',' 38 | }, 39 | debug: false, 40 | // react: { 41 | // wait: true 42 | // } 43 | }); 44 | 45 | export default i18n; 46 | -------------------------------------------------------------------------------- /src/renderer/i18n/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "ja", 3 | "LanguageTip": "言語", 4 | "Language": "日本語", 5 | "fontFamily": "フォント", 6 | "HeaderSubscribeTitle": "サブスクリプション", 7 | "HeaderLockTitle": "ロッキング", 8 | "HeaderCloseTitle": "上のウィンドウ", 9 | "LiveRoomPopular": "人気", 10 | "LiveRoomWatched": "見たことがある", 11 | "LiveRoomListsDelete": "削除する", 12 | "LiveRoomListsLoading": "遅いほど...", 13 | "DanmakuControlSetting": "セットアップ", 14 | "TranslateControlSetting": "翻訳設定", 15 | "DanmakuControlEffectBlock": "特殊効果", 16 | "DanmakuControlDanmakuBlock": "弾幕シールド", 17 | "DanmakuControlClearHistory": "弾幕の履歴をクリア", 18 | "DanmakuControlBlockScroll": "弾幕スクロールロック", 19 | "DanmakuControlUpdate": "更新", 20 | "DanmakuControlAbout": "オン", 21 | "GlobalSettingTitle": "全体設定", 22 | "GlobalSettingShowAvatar": "アバターを表示", 23 | "GlobalSettingShowFanLabel": "タイトルを表示", 24 | "GlobalSettingShowLvLabel": "表示レベル", 25 | "GlobalSettingShowVip": "おじいちゃんを表示", 26 | "TranslateSettingTitle": "翻訳設定", 27 | "AutoTranslate": "自動翻訳", 28 | "TranslateFrom": "ソース言語", 29 | "TranslateFromAuto": "自動", 30 | "TranslateTo": "翻訳対象言語", 31 | "VoiceSettingTitle": "音声再生をオンにする", 32 | "VoiceSettingShowVoice": "音声再生をオンにする", 33 | "VoiceTranslateTo": "ターゲット言語を読む", 34 | "VoiceSettingTaskMaxLength": "最大キュー", 35 | "VoiceSettingVolume": "再生音量", 36 | "VoiceSettingVoiceSpeed": "プレイ率", 37 | "DanmakuSettingTitle": "弾幕の設定", 38 | "DanmakuSettingMaxMessageCount": "弾幕の最大数", 39 | "DanmakuSettingMaxGiftCount": "ギフトの最大数", 40 | "DanmakuSettingBlockMinGoldSeed": "最も低いヒマワリの種を表示", 41 | "DanmakuSettingBlockMinSilverSeed": "最も低い銀のヒマワリの種を表示する", 42 | "DanmakuSettingShowTransition": "弾幕遷移アニメーション", 43 | "BackgroundColorTitle": "バックグラウンド", 44 | "BackgroundColorWhite": "白い", 45 | "BackgroundColorBlack": "黒", 46 | "BackgroundOpacity": "背景の透明度", 47 | "AvatarSize": "アバターのサイズ", 48 | "FontSize": "フォントサイズズーム", 49 | "FontLineHeight": "テキスト行の高さ", 50 | "FontMarginTop": "テキストの上余白", 51 | "ResetConfig": "リセット", 52 | "BlockEffectTitle": "特殊効果", 53 | "BlockEffectItem0": "シールドギフト弾幕", 54 | "BlockEffectItem1": "シールドドロー弾幕", 55 | "BlockEffectItem2": "ブロックエントリー情報", 56 | "BlockEffectItem3": "太字のメッセージをブロック", 57 | "BlockEffectItem4": "バブリングギフトのシールド", 58 | "BlockEffectItem5": "シールドキャプテンの弾幕効果", 59 | "GlobalBlockModeTitle": "グローバルブロック", 60 | "GlobalBlockModeHasBeen": "ブロックされました", 61 | "GlobalBlockModeOn": "開いた", 62 | "GlobalBlockModeOff": "シャットダウン", 63 | "GlobalBlockUserLv": "ユーザーレベル", 64 | "GlobalBlockUserLvUnder": "以下", 65 | "GlobalBlockUserNotMember": "非公式メンバー", 66 | "GlobalBlockUserNotBindPhone": "拘束されていない携帯電話ユーザー", 67 | "GlobalBlockKeyWordTitle": "キーワードシールド", 68 | "GlobalBlockKeyWordPlaceHolder": "ブロックしたいものを入力してください", 69 | "GlobalBlockKeyWordAdd": "追加", 70 | "GlobalBlockListsTitle": "ブロックリスト", 71 | "GlobalBlockKeyWordLists": "キーワードをブロック", 72 | "GlobalBlockUserLists": "ユーザーをブロック", 73 | "GlobalBlockCheck": "すべて選択", 74 | "GlobalBlockDelete": "削除する", 75 | "AboutTitle": "オン", 76 | "AboutSourceCode": "ソースコード", 77 | "CurrentVersion": "現行版", 78 | "LatestVersion": "の最新バージョン", 79 | "Author": "著者", 80 | "SocketDanmakuConnecting": "接続しています...", 81 | "SocketDisconnected": "古いソケットは閉じられています...", 82 | "SocketConnectSuccess": "接続に成功しました", 83 | "DanmakuTranslate": "翻訳", 84 | "DanmakuRead": "読み上げます", 85 | "DanmakuBlockUser": "送信者をブロック", 86 | "DanmakuBlockSimilar": "そのような弾幕をブロック", 87 | "DanmakuGuardBuy": "購入", 88 | "DanmakuGuardBuyInRoom": "この部屋に開通しました。", 89 | "DanmakuGuardBuyTime": "月", 90 | "DanmakuLive": "放送を開始", 91 | "DanmakuWelcome": "ようこそ", 92 | "DanmakuWelcomeLiveRoom": "ライブルームに入る", 93 | "DanmakuFollowedLiveRoom": "生放送室をフォローしました", 94 | "DanmakuBlockedUser": "ユーザー", 95 | "DanmakuBlockedText": "禁止された", 96 | "TranslateFailed": "翻訳に失敗しました", 97 | "CustomStyleTitle": "ユーザー定義のスタイル", 98 | "CustomStyledUserName": "ユーザーのニックネームスタイルをカスタマイズ", 99 | "CustomStyledDanmakuContent": "ユーザー定義の弾幕の内容スタイル", 100 | "ShowGiftDanmakuList": "ギフト弾幕リストを表示", 101 | "RankMessageList": "ゲストブック", 102 | "IgnoreMouse": "マウスの浸透", 103 | "UserConfig": "ユーザー構成", 104 | "Parameter": "パラメータ", 105 | "ParameterDescription": "パラメータの説明", 106 | "ParameterDescriptionText": "bilibiliプライバシーの制限のため、ログインしていない場合は他人のニックネームを見ることができません。ブラウザでbilibiliにログインした後、コンソールクッキーでSESSDATAの値を選択し、次の入力ボックスにコピーして貼り付けてクリックしてリフレッシュすればよい。", 107 | "SessionError": "uid取得に失敗しました。セッションが正しいか期限切れであるかを確認してください", 108 | "Refresh": "リフレッシュ" 109 | } 110 | -------------------------------------------------------------------------------- /src/renderer/i18n/locales/lang.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Language": "中文", 4 | "code": "zhCn" 5 | }, 6 | { 7 | "Language": "日本語", 8 | "code": "ja" 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /src/renderer/i18n/locales/zh-cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "zhCn", 3 | "LanguageTip": "语言", 4 | "Language": "中文", 5 | "fontFamily": "字体", 6 | "HeaderSubscribeTitle": "订阅", 7 | "HeaderLockTitle": "窗口置顶", 8 | "HeaderCloseTitle": "关闭", 9 | "LiveRoomPopular": "人气值", 10 | "LiveRoomWatched": "观看过", 11 | "LiveRoomListsDelete": "删除", 12 | "LiveRoomListsLoading": "越多越慢...", 13 | "DanmakuControlSetting": "设置", 14 | "TranslateControlSetting": "翻译设置", 15 | "DanmakuControlEffectBlock": "屏蔽特效", 16 | "DanmakuControlDanmakuBlock": "弹幕屏蔽", 17 | "DanmakuControlClearHistory": "清除弹幕历史", 18 | "DanmakuControlBlockScroll": "弹幕滚动锁定", 19 | "DanmakuControlUpdate": "更新", 20 | "DanmakuControlAbout": "关于", 21 | "GlobalSettingTitle": "全局设置", 22 | "GlobalSettingShowAvatar": "显示头像", 23 | "GlobalSettingShowFanLabel": "显示头衔", 24 | "GlobalSettingShowLvLabel": "显示等级", 25 | "GlobalSettingShowVip": "显示姥爷", 26 | "TranslateSettingTitle": "翻译设置", 27 | "AutoTranslate": "自动翻译(已失效)", 28 | "TranslateFrom": "源语言", 29 | "TranslateFromAuto": "自动", 30 | "TranslateTo": "翻译目标语言", 31 | "VoiceSettingTitle": "开启语音播放", 32 | "VoiceSettingShowVoice": "开启语音播放", 33 | "VoiceTranslateTo": "朗读目标语言", 34 | "VoiceSettingTaskMaxLength": "队列最大值", 35 | "VoiceSettingVolume": "播放音量", 36 | "VoiceSettingVoiceSpeed": "播放语速", 37 | "DanmakuSettingTitle": "弹幕设置", 38 | "DanmakuSettingMaxMessageCount": "弹幕最大条数", 39 | "DanmakuSettingMaxGiftCount": "礼物最大条数", 40 | "DanmakuSettingBlockMinGoldSeed": "显示最低金瓜子", 41 | "DanmakuSettingBlockMinSilverSeed": "显示最低银瓜子", 42 | "DanmakuSettingShowTransition": "弹幕过渡动画", 43 | "BackgroundColorTitle": "背景", 44 | "BackgroundColorWhite": "白色", 45 | "BackgroundColorBlack": "黑色", 46 | "BackgroundOpacity": "背景透明度", 47 | "AvatarSize": "头像大小", 48 | "FontSize": "字号缩放", 49 | "FontLineHeight": "文字行高", 50 | "FontMarginTop": "文字上边距", 51 | "ResetConfig": "重置", 52 | "BlockEffectTitle": "屏蔽特效", 53 | "BlockEffectItem0": "屏蔽礼物弹幕", 54 | "BlockEffectItem1": "屏蔽抽奖弹幕", 55 | "BlockEffectItem2": "屏蔽进场信息", 56 | "BlockEffectItem3": "屏蔽醒目留言", 57 | "BlockEffectItem4": "屏蔽冒泡礼物", 58 | "BlockEffectItem5": "屏蔽舰长弹幕特效", 59 | "BlockEffectItem6": "屏蔽表情动画", 60 | "GlobalBlockModeTitle": "全局屏蔽", 61 | "GlobalBlockModeHasBeen": "屏蔽已", 62 | "GlobalBlockModeOn": "开启", 63 | "GlobalBlockModeOff": "关闭", 64 | "GlobalBlockUserLv": "用户等级", 65 | "GlobalBlockUserLvUnder": "以下", 66 | "GlobalBlockUserNotMember": "非正式会员", 67 | "GlobalBlockUserNotBindPhone": "未绑定手机用户", 68 | "GlobalBlockKeyWordTitle": "关键词屏蔽", 69 | "GlobalBlockKeyWordPlaceHolder": "请输入您要屏蔽的内容", 70 | "GlobalBlockKeyWordAdd": "添加", 71 | "GlobalBlockListsTitle": "屏蔽列表", 72 | "GlobalBlockKeyWordLists": "屏蔽关键词", 73 | "GlobalBlockUserLists": "屏蔽用户", 74 | "GlobalBlockCheck": "全选", 75 | "GlobalBlockDelete": "删除", 76 | "AboutTitle": "关于", 77 | "AboutSourceCode": "源代码", 78 | "CurrentVersion": "当前版本", 79 | "LatestVersion": "最新版本", 80 | "Author": "作者", 81 | "SocketDanmakuConnecting": "连接中...", 82 | "SocketDisconnected": "旧的socket已经关闭...", 83 | "SocketConnectSuccess": "连接成功", 84 | "DanmakuTranslate": "翻译", 85 | "DanmakuRead": "朗读", 86 | "DanmakuBlockUser": "屏蔽发送者", 87 | "DanmakuBlockSimilar": "屏蔽此类弹幕", 88 | "DanmakuGuardBuy": "购买", 89 | "DanmakuGuardBuyInRoom": "在本房间开通了", 90 | "DanmakuGuardBuyTime": "月", 91 | "DanmakuLive": "开播啦", 92 | "DanmakuWelcome": "欢迎", 93 | "DanmakuWelcomeLiveRoom": "进入直播间", 94 | "DanmakuFollowedLiveRoom": "关注了直播间", 95 | "DanmakuBlockedUser": "用户", 96 | "DanmakuBlockedText": "已被禁言", 97 | "TranslateFailed": "翻译失败", 98 | "CustomStyleTitle": "自定义样式", 99 | "CustomStyledUserName": "自定义用户昵称样式", 100 | "CustomStyledDanmakuContent": "自定义弹幕内容样式", 101 | "ShowGiftDanmakuList": "显示礼物弹幕列表", 102 | "RankMessageList": "留言榜", 103 | "IgnoreMouse": "鼠标穿透", 104 | "UserConfig": "用户配置", 105 | "Parameter": "参数配置", 106 | "ParameterDescription": "参数说明", 107 | "ParameterDescriptionText": "由于bilibili隐私限制, 未登录情况下无法查看他人昵称。为了更好的体验可在浏览器登录bilibili后,在控制台cookie中选择SESSDATA的值, 复制粘贴到以下输入框点击刷新即可。", 108 | "SessionError": "uid获取失败,请检查session是否正确或是否已过期", 109 | "Refresh": "刷新" 110 | } 111 | -------------------------------------------------------------------------------- /src/renderer/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | bilive danmaku 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import App from './App'; 5 | import store from './store'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | 11 | 12 | , 13 | document.getElementById('root') 14 | ); 15 | -------------------------------------------------------------------------------- /src/renderer/reducers/types.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch as ReduxDispatch, Store as ReduxStore, Action } from 'redux'; 2 | import { SocketInstanceType } from '../components/Danmaku/base/Socket'; 3 | 4 | export enum ConfigKey { 5 | version = 'version', 6 | latestVersion = 'latestVersion', 7 | languageCode = 'languageCode', 8 | setAlwaysOnTop = 'setAlwaysOnTop', 9 | ignoreMouse = 'ignoreMouse', 10 | roomid = 'roomid', 11 | shortid = 'shortid', 12 | showAvatar = 'showAvatar', 13 | avatarSize = 'avatarSize', 14 | showFanLabel = 'showFanLabel', 15 | showLvLabel = 'showLvLabel', 16 | showVip = 'showVip', 17 | backgroundColor = 'backgroundColor', 18 | backgroundOpacity = 'backgroundOpacity', 19 | fontFamily = 'fontFamily', 20 | fontSize = 'fontSize', 21 | fontLineHeight = 'fontLineHeight', 22 | fontMarginTop = 'fontMarginTop', 23 | blockScrollBar = 'blockScrollBar', 24 | showVoice = 'showVoice', 25 | voiceVolume = 'voiceVolume', 26 | voiceSpeed = 'voiceSpeed', 27 | autoTranslate = 'autoTranslate', 28 | translateFrom = 'translateFrom', 29 | translateTo = 'translateTo', 30 | maxMessageCount = 'maxMessageCount', 31 | voiceTranslateTo = 'voiceTranslateTo', 32 | taskMaxLength = 'taskMaxLength', 33 | blockMode = 'blockMode', 34 | blockEffectItem0 = 'blockEffectItem0', 35 | blockEffectItem1 = 'blockEffectItem1', 36 | blockEffectItem2 = 'blockEffectItem2', 37 | blockEffectItem3 = 'blockEffectItem3', 38 | blockEffectItem4 = 'blockEffectItem4', 39 | blockEffectItem5 = 'blockEffectItem5', 40 | blockEffectItem6 = 'blockEffectItem6', 41 | blockMinGoldSeed = 'blockMinGoldSeed', 42 | blockMinSilverSeed = 'blockMinSilverSeed', 43 | blockDanmakuLists = 'blockDanmakuLists', 44 | blockUserLists = 'blockUserLists', 45 | blockUserLv = 'blockUserLv', 46 | blockUserNotMember = 'blockUserNotMember', 47 | blockUserNotBindPhone = 'blockUserNotBindPhone', 48 | showGiftDanmakuList = 'showGiftDanmakuList', 49 | maxDanmakuGiftCount = 'maxDanmakuGiftCount', 50 | danmakuGiftListHeight = 'danmakuGiftListHeight', 51 | showTransition = 'showTransition' 52 | } 53 | 54 | export type counterStateType = { 55 | count: number; 56 | }; 57 | 58 | export type danmakuStateType = { 59 | roomid: number; 60 | socket: SocketInstanceType; 61 | giftMap: Map; 62 | userAvatarMap: Map; 63 | }; 64 | 65 | export type rootStatePropsType = { 66 | counter: counterStateType; 67 | danmaku: danmakuStateType; 68 | config: ConfigStateType; 69 | }; 70 | 71 | export type GetState = () => rootStatePropsType; 72 | 73 | export type Dispatch = ReduxDispatch>; 74 | 75 | export type Store = ReduxStore>; 76 | -------------------------------------------------------------------------------- /src/renderer/store/features/configSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { RootState } from '../index'; 3 | import config, { setConfig } from '../../config'; 4 | import ConfigDao from '../../dao/ConfigDao'; 5 | import { getVersionInfo } from '../../api'; 6 | 7 | const initialState: ConfigStateType = { 8 | ...config, 9 | }; 10 | 11 | export const configSlice = createSlice({ 12 | name: 'config', 13 | initialState, 14 | reducers: { 15 | updateConfig(state, { payload }) { 16 | const { k, v } = payload; 17 | state[k] = v; 18 | setConfig(k, v); 19 | }, 20 | resetConfig(state) { 21 | state = ConfigDao.reset(); 22 | window.location.reload(); 23 | }, 24 | }, 25 | }); 26 | 27 | export const fetchVersionInfo = () => async (dispatch) => { 28 | const latestVersion = await getVersionInfo(); 29 | const versionData = { 30 | k: 'latestVersion', 31 | v: latestVersion, 32 | }; 33 | dispatch(updateConfig(versionData)); 34 | }; 35 | 36 | export const { updateConfig, resetConfig } = configSlice.actions; 37 | export const selectConfig = (state: RootState) => state.config; 38 | export default configSlice.reducer; 39 | -------------------------------------------------------------------------------- /src/renderer/store/features/counterSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; 2 | import { RootState } from '../index'; 3 | import { counterStateType } from '../../reducers/types'; 4 | 5 | const initialState: counterStateType = { 6 | count: 0, 7 | }; 8 | 9 | export const counterSlice = createSlice({ 10 | name: 'counter', 11 | initialState, 12 | reducers: { 13 | increment(state, { payload }) { 14 | state.count += payload.step; 15 | }, 16 | decrement(state) { 17 | state.count -= 1; 18 | }, 19 | }, 20 | }); 21 | 22 | export const asyncIncrement = (payload) => (dispatch) => { 23 | setTimeout(() => { 24 | dispatch(increment(payload)); 25 | }, 2000); 26 | }; 27 | 28 | export const { increment, decrement } = counterSlice.actions; 29 | export const selectCounter = (state: RootState) => state.counter; 30 | export default counterSlice.reducer; 31 | -------------------------------------------------------------------------------- /src/renderer/store/features/danmakuSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { RootState } from '../index'; 3 | import { danmakuStateType } from '../../reducers/types'; 4 | import config from '../../config'; 5 | import Socket from '../../components/Danmaku/base/Socket'; 6 | import { getGiftInfo } from '../../api'; 7 | 8 | const initialState: danmakuStateType = { 9 | roomid: config.roomid, 10 | socket: new Socket(config.roomid), 11 | giftMap: new Map(), 12 | userAvatarMap: new Map(), 13 | }; 14 | 15 | export const danmakuSlice = createSlice({ 16 | name: 'danmaku', 17 | initialState, 18 | reducers: { 19 | createSocket(state, action) { 20 | const roomid = action.payload; 21 | state.socket.close(); 22 | state.roomid = roomid; 23 | state.socket = new Socket(roomid); 24 | }, 25 | setGiftData(state, action) { 26 | state.giftMap = action.payload; 27 | }, 28 | }, 29 | }); 30 | 31 | export const fetchGiftData = () => async (dispatch) => { 32 | const giftMap = await getGiftInfo(); 33 | dispatch(setGiftData(giftMap)); 34 | }; 35 | 36 | export const { setGiftData, createSocket } = danmakuSlice.actions; 37 | export const selectDanmaku = (state: RootState) => state.danmaku; 38 | export default danmakuSlice.reducer; 39 | -------------------------------------------------------------------------------- /src/renderer/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 2 | import type { RootState, AppDispatch } from './index'; 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = () => useDispatch(); 6 | export const useAppSelector: TypedUseSelectorHook = useSelector; 7 | -------------------------------------------------------------------------------- /src/renderer/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import danmakuReducer from './features/danmakuSlice'; 3 | import counterReducer from './features/counterSlice'; 4 | import configReducer from './features/configSlice'; 5 | 6 | const store = configureStore({ 7 | middleware: (getDefaultMiddleware) => 8 | getDefaultMiddleware({ 9 | serializableCheck: false, 10 | }), 11 | reducer: { 12 | danmaku: danmakuReducer, 13 | counter: counterReducer, 14 | config: configReducer, 15 | }, 16 | }); 17 | 18 | export default store; 19 | export type RootState = ReturnType; 20 | export type AppDispatch = typeof store.dispatch; 21 | -------------------------------------------------------------------------------- /src/renderer/utils/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Beats0/bilive-danmaku/590e9f122929bc29659eeb8b44ff45a0890894c0/src/renderer/utils/.gitkeep -------------------------------------------------------------------------------- /src/renderer/utils/common.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer, shell } from "electron"; 2 | import { lt } from "semver"; 3 | 4 | function openLink(href: string) { 5 | shell.openExternal(href).catch(e => { 6 | console.warn(e); 7 | }); 8 | } 9 | 10 | function userAvatarFilter(avatar: string): string { 11 | const gifRe = /\.(gif)$/; 12 | const webpRe = /\.(webp)$/; 13 | const jpgRe = /\.(jpg)$/; 14 | if (gifRe.test(avatar) || webpRe.test(avatar)) return avatar; 15 | if (jpgRe.test(avatar)) return `${avatar}_64x64.jpg`; 16 | return avatar; 17 | } 18 | 19 | function toPercentNum(number: number): number { 20 | const numberP = Math.floor(number * 100); 21 | return numberP; 22 | } 23 | 24 | function isInclude(item: string | number, lists: string[] | number[]): boolean { 25 | return lists.includes(item); 26 | } 27 | 28 | function arrayDiff(arr1: any[], arr2: any[]): any[] { 29 | return arr1.filter(el => !arr2.includes(el)); 30 | } 31 | 32 | function setCssVariable(configKey: string, value: string | null) { 33 | const propertyName = `--primary-${configKey}`; 34 | document.documentElement.style.setProperty(propertyName, value); 35 | } 36 | 37 | // 太菜了,写法好low ( 38 | const sortBy = (sortKey: string, reverse = false, sortKeyPrefix?: string) => ( 39 | a, 40 | b 41 | ) => { 42 | if (sortKeyPrefix) { 43 | if (a[sortKeyPrefix][`${sortKey}`] < b[sortKeyPrefix][`${sortKey}`]) { 44 | return reverse ? -1 : 1; 45 | } 46 | if (a[sortKeyPrefix][`${sortKey}`] > b[sortKeyPrefix][`${sortKey}`]) { 47 | return reverse ? 1 : -1; 48 | } 49 | return 0; 50 | } 51 | if (a[`${sortKey}`] < b[`${sortKey}`]) { 52 | return reverse ? -1 : 1; 53 | } 54 | if (a[`${sortKey}`] > b[`${sortKey}`]) { 55 | return reverse ? 1 : -1; 56 | } 57 | return 0; 58 | }; 59 | 60 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 61 | 62 | function unionSet(array: any[], key: string) { 63 | const hash = {}; 64 | let result = []; 65 | result = array.reduce((item, next) => { 66 | hash[next[key]] ? '' : (hash[next[key]] = true && item.push(next)); 67 | return item; 68 | }, []); 69 | return result; 70 | } 71 | 72 | function tranNumber(num: number, point = 2): string { 73 | const numStr = num.toString(); 74 | if (numStr.length < 5) { 75 | return numStr; 76 | } 77 | const decimal = numStr.substring( 78 | numStr.length - 4, 79 | numStr.length - 4 + point 80 | ); 81 | return `${parseFloat(`${parseInt(String(num / 10000), 10)}.${decimal}`)}w`; 82 | } 83 | 84 | /** 85 | * @param {string} clientVersion 客户端版本 86 | * @param {string} serverVersion 服务器端版本. 87 | * @return {boolean} 判断是否能更新 88 | */ 89 | const hasNewVersion = ( 90 | clientVersion: string, 91 | serverVersion: string 92 | ): boolean => { 93 | return lt(clientVersion, serverVersion); 94 | }; 95 | 96 | /** 97 | * 时间格式化 98 | * @param {Date} date 99 | * @param {string} fmt YYYY-mm-dd HH:MM => 2022-02-15 19:45 100 | * @return {string} 101 | * */ 102 | function dateFormat(date: Date, fmt: string) { 103 | let ret; 104 | const opt = { 105 | "Y+": date.getFullYear().toString(), 106 | "m+": (date.getMonth() + 1).toString(), 107 | "d+": date.getDate().toString(), 108 | "H+": date.getHours().toString(), 109 | "M+": date.getMinutes().toString(), 110 | "S+": date.getSeconds().toString() 111 | }; 112 | for (let k in opt) { 113 | ret = new RegExp("(" + k + ")").exec(fmt); 114 | if (ret) { 115 | fmt = fmt.replace(ret[1], (ret[1].length == 1) ? (opt[k]) : (opt[k].padStart(ret[1].length, "0"))) 116 | }; 117 | }; 118 | return fmt; 119 | } 120 | 121 | export const systemFonts: string[] = []; 122 | 123 | // 获取系统字体列表 124 | ipcRenderer.send('getSystemFonts'); 125 | ipcRenderer.on('getSystemFontsCb', (e, fonts: string[] = []) => { 126 | // console.log('fonts', fonts); 127 | systemFonts = fonts; 128 | }); 129 | 130 | export { 131 | openLink, 132 | userAvatarFilter, 133 | toPercentNum, 134 | isInclude, 135 | arrayDiff, 136 | setCssVariable, 137 | sortBy, 138 | sleep, 139 | unionSet, 140 | tranNumber, 141 | hasNewVersion, 142 | dateFormat, 143 | }; 144 | -------------------------------------------------------------------------------- /src/renderer/utils/convert.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 字符串转化为Byte字节 3 | * @param {String} str 要转化的字符串 4 | * @return {Array[byte]} 字节数组 5 | */ 6 | function str2bytes(str: string): number[] { 7 | const bytes = []; 8 | let c; 9 | const len = str.length; 10 | for (let i = 0; i < len; i++) { 11 | c = str.charCodeAt(i); 12 | if (c >= 0x010000 && c <= 0x10ffff) { 13 | bytes.push(((c >> 18) & 0x07) | 0xf0); 14 | bytes.push(((c >> 12) & 0x3f) | 0x80); 15 | bytes.push(((c >> 6) & 0x3f) | 0x80); 16 | bytes.push((c & 0x3f) | 0x80); 17 | } else if (c >= 0x000800 && c <= 0x00ffff) { 18 | bytes.push(((c >> 12) & 0x0f) | 0xe0); 19 | bytes.push(((c >> 6) & 0x3f) | 0x80); 20 | bytes.push((c & 0x3f) | 0x80); 21 | } else if (c >= 0x000080 && c <= 0x0007ff) { 22 | bytes.push(((c >> 6) & 0x1f) | 0xc0); 23 | bytes.push((c & 0x3f) | 0x80); 24 | } else { 25 | bytes.push(c & 0xff); 26 | } 27 | } 28 | return bytes; 29 | } 30 | 31 | /** 32 | * 将字节数组转化为字符串 33 | * @param {Array[byte]} bytesArray 字节数组 34 | * @return {String} 字符串 35 | */ 36 | function bytes2str(array: number[]): string { 37 | const bytes = array.slice(0); 38 | const filterArray = [ 39 | [0x7f], 40 | [0x1f, 0x3f], 41 | [0x0f, 0x3f, 0x3f], 42 | [0x07, 0x3f, 0x3f, 0x3f] 43 | ]; 44 | let j; 45 | let str = ''; 46 | for (let i = 0; i < bytes.length; i += j) { 47 | const item = bytes[i]; 48 | let number = ''; 49 | if (item >= 240) { 50 | j = 4; 51 | } else if (item >= 224) { 52 | j = 3; 53 | } else if (item >= 192) { 54 | j = 2; 55 | } else if (item < 128) { 56 | j = 1; 57 | } 58 | const filter = filterArray[j - 1]; 59 | for (let k = 0; k < j; k++) { 60 | let r = (bytes[i + k] & filter[k]).toString(2); 61 | const l = r.length; 62 | if (l > 6) { 63 | number = r; 64 | break; 65 | } 66 | for (let n = 0; n < 6 - l; n++) { 67 | r = `0${r}`; 68 | } 69 | number += r; 70 | } 71 | str += String.fromCharCode(parseInt(number, 2)); 72 | } 73 | return str; 74 | } 75 | 76 | export { str2bytes, bytes2str }; 77 | -------------------------------------------------------------------------------- /src/renderer/utils/json-parser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 解析字符串内包含的对象 3 | * @param {String} str 传入字符串 4 | * @returns {Array[Object]} 5 | */ 6 | function parser(str: string): any[] { 7 | let i = -1; 8 | const len = str.length; 9 | const result = []; 10 | 11 | const store = []; 12 | while (i++ < len - 1) { 13 | if (str[i] === '{') { 14 | store.push({ 15 | index: i 16 | }); 17 | } 18 | if (str[i] === '}') { 19 | const prev = store.pop(); 20 | if (!prev) { 21 | console.warn(`${str}不是正确的对象字符串`); 22 | continue; 23 | } 24 | if (store.length === 0) { 25 | result.push(str.slice(prev.index, i + 1)); 26 | } 27 | } 28 | } 29 | 30 | return result; 31 | } 32 | 33 | export { parser }; 34 | -------------------------------------------------------------------------------- /src/renderer/utils/packet.ts: -------------------------------------------------------------------------------- 1 | import { str2bytes } from './convert'; 2 | 3 | /** 4 | * 生成对应的消息包 5 | * @param {Number} action 2是心跳包/7是加入房间 6 | * @param {String} payload 7 | */ 8 | function generatePacket(action = 2, payload = ''): DataView { 9 | const packet = str2bytes(payload); 10 | const buff = new ArrayBuffer(packet.length + 16); 11 | const dataBuf = new DataView(buff); 12 | dataBuf.setUint32(0, packet.length + 16); 13 | dataBuf.setUint16(4, 16); 14 | dataBuf.setUint16(6, 1); 15 | dataBuf.setUint32(8, action); 16 | dataBuf.setUint32(12, 1); 17 | for (let i = 0; i < packet.length; i++) { 18 | dataBuf.setUint8(16 + i, packet[i]); 19 | } 20 | return dataBuf; 21 | } 22 | 23 | export { generatePacket }; 24 | -------------------------------------------------------------------------------- /src/renderer/utils/safeEval.ts: -------------------------------------------------------------------------------- 1 | import vm, { Context, RunningScriptOptions } from 'vm'; 2 | 3 | export default function safeEval( 4 | code: string, 5 | context?: Context, 6 | opts?: RunningScriptOptions 7 | ) { 8 | const sandbox: Context = {}; 9 | const resultKey = `SAFE_EVAL_${Math.floor(Math.random() * 1000000)}`; 10 | sandbox[resultKey] = {}; 11 | const clearContext = ` 12 | (function(){ 13 | Function = undefined; 14 | const keys = Object.getOwnPropertyNames(this).concat(['constructor']); 15 | keys.forEach((key) => { 16 | const item = this[key]; 17 | if(!item || typeof item.constructor !== 'function') return; 18 | this[key].constructor = undefined; 19 | }); 20 | })(); 21 | `; 22 | code = `${clearContext + resultKey}=${code}`; 23 | if (context) { 24 | Object.keys(context).forEach(function(key) { 25 | sandbox[key] = context[key]; 26 | }); 27 | } 28 | vm.runInNewContext(code, sandbox, opts); 29 | return sandbox[resultKey]; 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/utils/ttk.ts: -------------------------------------------------------------------------------- 1 | import Configstore from 'configstore'; 2 | 3 | // BEGIN 4 | function sM(a: string) { 5 | let b; 6 | if (yr !== null) b = yr; 7 | else { 8 | b = wr(String.fromCharCode(84)); 9 | var c = wr(String.fromCharCode(75)); 10 | b = [b(), b()]; 11 | b[1] = c(); 12 | b = (yr = window[b.join(c())] || '') || ''; 13 | } 14 | var d = wr(String.fromCharCode(116)); 15 | var c = wr(String.fromCharCode(107)); 16 | var d = [d(), d()]; 17 | d[1] = c(); 18 | c = `&${d.join('')}=`; 19 | d = b.split('.'); 20 | b = Number(d[0]) || 0; 21 | for (var e = [], f = 0, g = 0; g < a.length; g++) { 22 | let l = a.charCodeAt(g); 23 | l < 128 24 | ? (e[f++] = l) 25 | : (l < 2048 26 | ? (e[f++] = (l >> 6) | 192) 27 | : ((l & 64512) == 55296 && 28 | g + 1 < a.length && 29 | (a.charCodeAt(g + 1) & 64512) == 56320 30 | ? ((l = 65536 + ((l & 1023) << 10) + (a.charCodeAt(++g) & 1023)), 31 | (e[f++] = (l >> 18) | 240), 32 | (e[f++] = ((l >> 12) & 63) | 128)) 33 | : (e[f++] = (l >> 12) | 224), 34 | (e[f++] = ((l >> 6) & 63) | 128)), 35 | (e[f++] = (l & 63) | 128)); 36 | } 37 | a = b; 38 | for (f = 0; f < e.length; f++) (a += e[f]), (a = xr(a, '+-a^+6')); 39 | a = xr(a, '+-3^+b+-f'); 40 | a ^= Number(d[1]) || 0; 41 | a < 0 && (a = (a & 2147483647) + 2147483648); 42 | a %= 1e6; 43 | return `${c}${a.toString()}.${a ^ b}`; 44 | } 45 | 46 | var yr = null; 47 | var wr = function(a: string) { 48 | return function() { 49 | return a; 50 | }; 51 | }; 52 | var xr = function(a: string, b: string) { 53 | for (let c = 0; c < b.length - 2; c += 3) { 54 | var d = b.charAt(c + 2); 55 | var d = d >= 'a' ? d.charCodeAt(0) - 87 : Number(d); 56 | var d = b.charAt(c + 1) == '+' ? a >>> d : a << d; 57 | a = b.charAt(c) == '+' ? (a + d) & 4294967295 : a ^ d; 58 | } 59 | return a; 60 | }; 61 | 62 | // END 63 | const config = new Configstore('google-translate-api'); 64 | 65 | var window = { 66 | TKK: config.get('TKK') || '0' 67 | }; 68 | 69 | export function updateTKK() { 70 | return new Promise(function(resolve, reject) { 71 | const now = Math.floor(Date.now() / 3600000); 72 | 73 | if (Number(window.TKK.split('.')[0]) === now) { 74 | resolve(); 75 | } else { 76 | fetch('https://translate.google.cn') 77 | .then(async function(res) { 78 | const textString: string = await res.text(); 79 | const code = textString.match(/TKK=(.*?)\(\)\)'\);/g); 80 | 81 | if (code) { 82 | eval(code[0]); 83 | /* eslint-disable no-undef */ 84 | if (typeof TKK !== 'undefined') { 85 | window.TKK = TKK; 86 | config.set('TKK', TKK); 87 | } 88 | } 89 | 90 | /** 91 | * Note: If the regex or the eval fail, there is no need to worry. The server will accept 92 | * relatively old seeds. 93 | */ 94 | 95 | resolve(); 96 | }) 97 | .catch(function(err) { 98 | const e = new Error(); 99 | e.code = 'BAD_NETWORK'; 100 | e.message = err.message; 101 | reject(e); 102 | }); 103 | } 104 | }); 105 | } 106 | 107 | export function get(text: string) { 108 | return updateTKK() 109 | .then(function() { 110 | let tk = sM(text); 111 | tk = tk.replace('&tk=', ''); 112 | return { name: 'tk', value: tk }; 113 | }) 114 | .catch(function(err) { 115 | throw err; 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /src/renderer/utils/vioce.ts: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | import { 3 | currentTranslateToCode, 4 | getTranslateTTSUrl, 5 | translate 6 | } from './translation'; 7 | 8 | const $audio = document.createElement('audio'); 9 | const $source = document.createElement('source'); 10 | $audio.volume = config.voiceVolume; 11 | $audio.playbackRate = config.voiceSpeed; 12 | $source.setAttribute('type', 'audio/mp3'); 13 | $source.setAttribute('src', ''); 14 | $audio.appendChild($source); 15 | // 防止electron主进程还拿不到body导致渲染白版 16 | document?.body.appendChild($audio); 17 | 18 | async function baiduTTS(fullText: string) { 19 | console.log('[baiduTTS translate]', fullText); 20 | const url = `http://tts.baidu.com/text2audio?cuid=baike&lan=zh&ctp=1&pdt=301&tex=${fullText}`; 21 | const res = await fetch(url); 22 | const blob = await res.blob(); 23 | const resData = { 24 | res, 25 | blob 26 | }; 27 | return resData; 28 | } 29 | 30 | async function googleTTS(fullText: string) { 31 | console.log('[googleTTS translate]', fullText); 32 | const url = await getTranslateTTSUrl(fullText); 33 | const res = await fetch(url); 34 | const blob = await res.blob(); 35 | const resData = { 36 | res, 37 | blob 38 | }; 39 | return resData; 40 | } 41 | 42 | // TODO: edgeTTS see: https://github.com/koodo-reader/koodo-reader/blob/fcc8a6f014f20b1bf5c1b2b2dacc0761905646d5/edge-tts.js 43 | async function edgeTTS() { 44 | 45 | } 46 | 47 | export async function read(uname: string, text: string) { 48 | const fullText = `${uname} 说 ${text}`; 49 | let res; 50 | let blob; 51 | try { 52 | let resData; 53 | // googleTTS 54 | // google 翻译挂了 = = 55 | // const googleTranslateRes = await translate(text, { 56 | // from: 'auto', 57 | // to: currentTranslateToCode() 58 | // }); 59 | // console.log(googleTranslateRes.text); 60 | // const { iso } = googleTranslateRes.from.language; 61 | // let resData = {}; 62 | // if (config.voiceTranslateTo === 'zhCn' && iso === 'zh-CN') { 63 | // resData = await baiduTTS(fullText); 64 | // } else { 65 | // resData = await googleTTS(`${googleTranslateRes.text}`); 66 | // } 67 | 68 | // 直接用百度TTS 69 | resData = await baiduTTS(fullText); 70 | 71 | // edgeTTS 72 | // resData = await edgeTTS(fullText); 73 | 74 | res = resData.res; 75 | blob = resData.blob; 76 | 77 | if (res.status !== 200) { 78 | console.warn('合成语言失败'); 79 | return; 80 | } 81 | $source.setAttribute('src', URL.createObjectURL(blob)); 82 | $audio.load(); 83 | 84 | try { 85 | const playEndPromise = new Promise(resolve => { 86 | $audio.onended = resolve; 87 | }); 88 | await $audio.play(); 89 | return playEndPromise; 90 | } catch (err) { 91 | console.warn('语言朗读消息失败', err.message); 92 | } 93 | } catch (err) { 94 | console.warn('语言朗读消息失败', err.message); 95 | } 96 | } 97 | 98 | type Task = { 99 | uname: string; 100 | text: string; 101 | }; 102 | 103 | export type TaskConfig = { 104 | taskLength: number; 105 | taskMaxLength: number; 106 | }; 107 | 108 | let taskQueue: Task[] = []; 109 | let isWorking = false; 110 | 111 | export function queryTask(): TaskConfig { 112 | const taskConfig: TaskConfig = { 113 | taskLength: taskQueue.length, 114 | taskMaxLength: config.taskMaxLength 115 | }; 116 | return taskConfig; 117 | } 118 | 119 | async function handleTaskQueue() { 120 | isWorking = true; 121 | const task = taskQueue.shift(); 122 | if (task) { 123 | await read(task.uname, task.text); 124 | await handleTaskQueue(); 125 | } else { 126 | isWorking = false; 127 | } 128 | } 129 | 130 | const voice = { 131 | push(uname: string, text: string) { 132 | if (taskQueue.length >= config.taskMaxLength) return; 133 | if (taskQueue.some(t => t.text === text)) return; 134 | taskQueue.push({ uname, text }); 135 | if (!isWorking) { 136 | handleTaskQueue(); 137 | } 138 | }, 139 | resetPush(uname: string, text: string) { 140 | this.reset(); 141 | this.push(uname, text); 142 | }, 143 | reset() { 144 | taskQueue = []; 145 | isWorking = false; 146 | }, 147 | updateVolume(volume: number) { 148 | $audio.volume = volume; 149 | }, 150 | updatePlaybackRate(rate: number) { 151 | $audio.playbackRate = rate; 152 | } 153 | }; 154 | 155 | export default voice; 156 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "module": "commonjs", 5 | "lib": ["dom", "esnext"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "jsx": "react-jsx", 9 | "strict": true, 10 | "pretty": true, 11 | "sourceMap": true, 12 | "baseUrl": "./src", 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "moduleResolution": "node", 18 | "esModuleInterop": true, 19 | "allowSyntheticDefaultImports": true, 20 | "resolveJsonModule": true, 21 | "allowJs": true, 22 | "outDir": "release/app/dist" 23 | }, 24 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"] 25 | } 26 | --------------------------------------------------------------------------------