├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .idea ├── TOMATOX.iml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── img │ ├── electron.png │ ├── jest.png │ ├── logo.png │ ├── react.png │ ├── redux.png │ ├── ts.png │ └── webpack.png ├── index.html └── product │ ├── TOMATOX-1.png │ ├── TOMATOX-2.png │ ├── TOMATOX-3.png │ ├── TOMATOX-4.png │ ├── TOMATOX-5.png │ ├── TOMATOX-6.png │ ├── TOMATOX-7.png │ ├── TOMATOX-8.png │ └── TOMATOX-9.png ├── license.txt ├── package-lock.json ├── package.json ├── public ├── icon.icns ├── icon.ico ├── icon.png ├── icon256.ico ├── icon256.png ├── index.html ├── manifest.json └── robots.txt ├── src ├── main │ ├── auto-update │ │ └── auto-update.ts │ ├── event-handler │ │ └── event-handler.ts │ ├── main.ts │ └── utils │ │ └── deleteDir.ts └── renderer │ ├── app.css │ ├── app.tsx │ ├── components │ ├── custom-spin │ │ ├── custom-spin.scss │ │ └── custom-spin.tsx │ ├── global-loading │ │ ├── global-loading.scss │ │ └── global-loading.tsx │ ├── layout │ │ ├── content │ │ │ ├── custom-content.scss │ │ │ └── custom-content.tsx │ │ ├── custom-layout.scss │ │ ├── custom-layout.tsx │ │ ├── header │ │ │ ├── custom-header.scss │ │ │ └── custom-header.tsx │ │ └── sider │ │ │ ├── custom-sider.scss │ │ │ └── custom-sider.tsx │ ├── theme │ │ └── theme.tsx │ └── tomatox-waterfall │ │ ├── tomatox-waterfall.scss │ │ └── tomatox-waterfall.tsx │ ├── images │ └── svg │ │ └── icon.svg │ ├── styles │ └── overwrite.css │ ├── typing │ └── typings.d.ts │ ├── utils │ ├── constants.ts │ ├── db │ │ ├── indexed.ts │ │ └── storage.ts │ ├── filterResources.ts │ ├── openBrowser.js │ ├── request │ │ ├── index.ts │ │ └── modules │ │ │ ├── queryIptv.ts │ │ │ └── queryResources.ts │ ├── store.ts │ └── xmlParser.ts │ └── views │ ├── about │ ├── about.scss │ └── about.tsx │ ├── classify │ ├── classify.scss │ └── classify.tsx │ ├── collect │ ├── collect.scss │ └── collect.tsx │ ├── developing │ ├── developing.scss │ └── developing.tsx │ ├── history │ ├── history.scss │ └── history.tsx │ ├── iptv │ ├── iptv-player │ │ ├── iptv-player.scss │ │ └── iptv-player.tsx │ ├── iptv.scss │ └── iptv.tsx │ ├── player │ ├── palyer.scss │ └── player.tsx │ ├── recommend │ ├── recommend.scss │ └── recommend.tsx │ ├── search │ ├── search.scss │ └── search.tsx │ └── setting │ ├── setting.scss │ └── setting.tsx ├── test ├── components │ ├── Counter.spec.tsx │ └── __snapshots__ │ │ └── Counter.spec.tsx.snap └── e2e │ └── example.spec.ts ├── tsconfig.json ├── webpack.base.config.js ├── webpack.main.config.js ├── webpack.main.prod.config.js ├── webpack.renderer.config.js ├── webpack.renderer.dev.config.js └── webpack.renderer.prod.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "airbnb", 10 | "eslint:recommended", 11 | "plugin:import/recommended", 12 | "plugin:import/typescript", 13 | "plugin:import/electron", 14 | "plugin:react/recommended", 15 | "plugin:@typescript-eslint/eslint-recommended", 16 | "plugin:@typescript-eslint/recommended", 17 | "prettier", 18 | "prettier/react", 19 | "prettier/@typescript-eslint" 20 | ], 21 | "globals": { 22 | "Atomics": "readonly", 23 | "SharedArrayBuffer": "readonly" 24 | }, 25 | "parser": "@typescript-eslint/parser", 26 | "parserOptions": { 27 | "ecmaFeatures": { 28 | "jsx": true 29 | }, 30 | "ecmaVersion": 2018, 31 | "sourceType": "module", 32 | "project": "./tsconfig.json" 33 | }, 34 | "plugins": [ 35 | "react", 36 | "prettier", 37 | "@typescript-eslint" 38 | ], 39 | "rules": { 40 | "global-require": 0, 41 | "import/prefer-default-export": 0, 42 | "import/no-useless-path-segments": 1, 43 | "import/no-unresolved": 0, 44 | "import/no-extraneous-dependencies": 0, 45 | "import/no-named-as-default": 0, 46 | "import/newline-after-import": 1, 47 | "import/no-named-as-default-member": 0, 48 | "import/namespace": 0, 49 | "import/extensions": 0, 50 | "import/named": 0, 51 | "react/jsx-wrap-multilines": [ 52 | 2, 53 | { 54 | "declaration": "parens-new-line", 55 | "assignment": "parens-new-line", 56 | "return": "parens-new-line", 57 | "arrow": "ignore", 58 | "condition": "ignore", 59 | "logical": "ignore", 60 | "prop": "ignore" 61 | } 62 | ], 63 | "react/jsx-filename-extension": 0, 64 | "react/jsx-indent": 0, 65 | "react/jsx-boolean-value": 0, 66 | "react/jsx-closing-tag-location": 0, 67 | "react/jsx-closing-bracket-location": [ 68 | 2, 69 | { 70 | "selfClosing": "props-aligned", 71 | "nonEmpty": "after-props" 72 | } 73 | ], 74 | "react/prop-types": 0, 75 | "react/button-has-type": 0, 76 | "react/jsx-tag-spacing": [ 77 | 2, 78 | { 79 | "beforeSelfClosing": "always" 80 | } 81 | ], 82 | "react/jsx-one-expression-per-line": 0, 83 | "react/jsx-curly-spacing": 0, 84 | "react/jsx-curly-brace-presence": 0, 85 | "react/no-access-state-in-setstate": 0, 86 | "react/destructuring-assignment": 0, 87 | "react/jsx-no-bind": 0, 88 | "react/require-default-props": 0, 89 | "react/display-name": 0, 90 | "react/jsx-first-prop-new-line": 0, 91 | "react/jsx-props-no-spreading": 0, 92 | "react/static-property-placement": 0, 93 | "react/state-in-constructor": 0, 94 | "@typescript-eslint/no-var-requires": 0, 95 | "@typescript-eslint/indent": 0, 96 | "@typescript-eslint/camelcase": 0, 97 | "@typescript-eslint/explicit-function-return-type": 0, 98 | "@typescript-eslint/no-non-null-assertion": 0, 99 | "@typescript-eslint/no-use-before-define": 0, 100 | "@typescript-eslint/member-delimiter-style": 0, 101 | "@typescript-eslint/no-unused-vars": 0, 102 | "@typescript-eslint/no-explicit-any": 0, 103 | "@typescript-eslint/explicit-member-accessibility": 0, 104 | "@typescript-eslint/no-angle-bracket-type-assertion": 0, 105 | "@typescript-eslint/ban-ts-ignore": 0, 106 | "@typescript-eslint/no-this-alias": 0, 107 | "no-unused-expressions": "off", 108 | "jsx-a11y/alt-text": 0, 109 | "jsx-a11y/click-events-have-key-events": 0, 110 | "jsx-a11y/no-static-element-interactions": 0, 111 | "no-param-reassign": 0, 112 | "no-underscore-dangle": 0, 113 | "no-restricted-syntax": 0, 114 | "prefer-destructuring": 0, 115 | "no-plusplus": 0, 116 | "no-multi-assign": 0, 117 | "react/no-deprecated": 0, 118 | "lines-between-class-members": 0, 119 | "react/sort-comp": 0, 120 | "class-methods-use-this": 0, 121 | "react/no-string-refs": 0, 122 | "guard-for-in": 0, 123 | "no-await-in-loop": 0, 124 | "prefer-rest-params": 0, 125 | "react/no-danger": 0, 126 | "no-useless-constructor": 0, 127 | "react/no-array-index-key": 0, 128 | "import/order": 0 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | 2 | # main.yml 3 | 4 | # Workflow's name 5 | name: Build TOMATOX For Win/Mac/Linux 6 | 7 | on: 8 | push: 9 | tags: 10 | - v*.*.* 11 | 12 | jobs: 13 | release: 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | matrix: 18 | os: [windows-latest, macos-latest, ubuntu-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v1 23 | with: 24 | node-version: 12 25 | 26 | - run: | 27 | npm install 28 | npm run release 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | release/ 4 | .idea/ -------------------------------------------------------------------------------- /.idea/TOMATOX.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 21 | 22 | 24 | 25 | 27 | 28 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100, 4 | "tabWidth": 4 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present R. Franken 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![TOMATOX](docs/img/logo.png)](https://github.com/yanjiaxuan/TOMATOX/releases) 2 | # TOMATOX 3 | 4 | ### A Online video player with TypeScript, React, and Electron. 5 | - 🎞 全网在线VIP视频解析 6 | - 🎨 贯彻精致简洁的设计风格 7 | - 👑 PC全平台支持(Windows, Linux, MacOS) 8 | - ✨ 新功能陆续上线中... 9 | 10 | ### About Project 11 | [![React](docs/img/react.png)](https://reactjs.org/) 12 | [![Webpack](docs/img/webpack.png)](https://webpack.js.org/) 13 | [![TypeScript](docs/img/ts.png)](https://www.typescriptlang.org/) 14 | [![Electron](docs/img/electron.png)](https://electronjs.org/) 15 | 16 | [Electron](https://electronjs.org/) application boilerplate based on [React](https://reactjs.org/) and [Webpack](https://webpack.js.org/) for rapid application development using [TypeScript](https://www.typescriptlang.org/). 17 | 18 | ## Screenshot 19 | 20 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-1.png?raw=true) 21 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-2.png?raw=true) 22 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-3.png?raw=true) 23 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-4.png?raw=true) 24 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-5.png?raw=true) 25 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-6.png?raw=true) 26 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-7.png?raw=true) 27 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-8.png?raw=true) 28 | ![image](https://github.com/yanjiaxuan/TOMATOX/blob/main/docs/product/TOMATOX-9.png?raw=true) 29 | 30 | ## Install 31 | Clone the repository with Git: 32 | 33 | ```bash 34 | git clone --depth=1 git@github.com:yanjiaxuan/TOMATOX.git 35 | ``` 36 | 37 | Setting npm registry and electron mirror address 38 | 39 | ```bash 40 | npm config set registry https://mirrors.huaweicloud.com/repository/npm/ 41 | npm config set disturl https://mirrors.huaweicloud.com/nodejs/ 42 | npm config set electron_mirror https://mirrors.huaweicloud.com/electron/ 43 | ``` 44 | 45 | And then install the dependencies: 46 | 47 | ```bash 48 | cd 49 | npm install 50 | ``` 51 | 52 | ## Usage 53 | Both processes have to be started **simultaneously** in different console tabs: 54 | 55 | ```bash 56 | npm run start-renderer-dev 57 | npm run start-main-dev 58 | ``` 59 | 60 | This will start the application with hot-reload so you can instantly start developing your application. 61 | 62 | You can also run do the following to start both in a single process: 63 | 64 | ```bash 65 | npm run start-dev 66 | ``` 67 | 68 | ## Packaging 69 | We use [Electron builder](https://www.electron.build/) to build and package the application. By default you can run the following to package for your current platform: 70 | 71 | ```bash 72 | npm run dist 73 | ``` 74 | 75 | This will create a installer for your platform in the `releases` folder. 76 | 77 | You can make builds for specific platforms (or multiple platforms) by using the options found [here](https://www.electron.build/cli). E.g. building for all platforms (Windows, Mac, Linux): 78 | 79 | ```bash 80 | npm run dist -- -mwl 81 | ``` 82 | 83 | ## Husky and Prettier 84 | This project comes with both Husky and Prettier setup to ensure a consistent code style. 85 | 86 | To change the code style, you can change the configuration in `.prettierrc`. 87 | 88 | In case you want to get rid of this, you can removing the following from `package.json`: 89 | 90 | 1. Remove `precommit` from the `scripts` section 91 | 1. Remove the `lint-staged` section 92 | 1. Remove `lint-staged`, `prettier`, `eslint-config-prettier`, and `husky` from the `devDependencies` 93 | 94 | Also remove all mentions of Prettier from the `extends` section in `.eslintrc.json`. 95 | 96 | ### 版权声明 97 | 98 | 本人发布的所有资源或软件均来自网络,与本人没有任何关系,只能作为私下交流、学习、研究之用,版权归原作者及原软件公司所有。 99 | 100 | 本人发布的所有资源或软件请在下载后24小时内自行删除。如果您喜欢这个资源或软件,请联系原作者或原软件公司购买正版。与本人无关! 101 | 102 | 本人仅仅提供一个私下交流、学习、研究的环境,将不对任何资源或软件负法律责任! 103 | 104 | 任何涉及商业盈利性目的的单位或个人,均不得使用本人发布的资源或软件,否则产生的一切后果将由使用者自己承担! 105 | 106 | ## License 107 | MIT © [yanjiaxuan](https://github.com/yanjiaxuan) 108 | -------------------------------------------------------------------------------- /docs/img/electron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/electron.png -------------------------------------------------------------------------------- /docs/img/jest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/jest.png -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/logo.png -------------------------------------------------------------------------------- /docs/img/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/react.png -------------------------------------------------------------------------------- /docs/img/redux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/redux.png -------------------------------------------------------------------------------- /docs/img/ts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/ts.png -------------------------------------------------------------------------------- /docs/img/webpack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/img/webpack.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/product/TOMATOX-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-1.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-2.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-3.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-4.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-5.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-6.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-7.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-8.png -------------------------------------------------------------------------------- /docs/product/TOMATOX-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/docs/product/TOMATOX-9.png -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2022 FreeIess, http://github.com/yanjiaxuan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tomatox", 3 | "version": "2.0.2", 4 | "description": "A free fast beautiful online video player with electron.", 5 | "main": "./dist/main.js", 6 | "scripts": { 7 | "build-main": "cross-env NODE_ENV=production webpack --config webpack.main.prod.config.js", 8 | "build-renderer": "cross-env NODE_ENV=production webpack --config webpack.renderer.prod.config.js", 9 | "build": "npm run build-main && npm run build-renderer", 10 | "start-renderer-dev": "webpack-dev-server --config webpack.renderer.dev.config.js", 11 | "start-main-dev": "webpack --config webpack.main.config.js && electron ./dist/main.js", 12 | "start-dev": "cross-env START_HOT=1 npm run start-renderer-dev", 13 | "prestart": "npm run build", 14 | "start": "electron .", 15 | "lint": "eslint --ext=jsx,js,tsx,ts src --fix", 16 | "test": "jest '(\\/test\\/(?!e2e/)).*'", 17 | "pretest:e2e": "npm run build", 18 | "test:e2e": "jest '(\\/test\\/e2e/).*'", 19 | "unpacked": "npm run build && electron-builder --dir", 20 | "pack": "npm run build && electron-builder", 21 | "postinstall": "electron-builder install-app-deps", 22 | "release": "npm run build && electron-builder -p always" 23 | }, 24 | "husky": { 25 | "hooks": { 26 | "pre-commit": "lint-staged" 27 | } 28 | }, 29 | "lint-staged": { 30 | "{src,test,mocks}/**/*.{json,css,scss,md}": [ 31 | "prettier --config ./.prettierrc --write" 32 | ], 33 | "{src,test,mocks}/**/*.{js,ts,tsx}": [ 34 | "prettier --config ./.prettierrc --write", 35 | "eslint --ext=jsx,js,ts,tsx --fix src" 36 | ] 37 | }, 38 | "jest": { 39 | "transform": { 40 | "^.+\\.tsx?$": "ts-jest" 41 | }, 42 | "testRegex": "(/test/.+\\.spec)\\.tsx?$", 43 | "moduleFileExtensions": [ 44 | "ts", 45 | "tsx", 46 | "js", 47 | "json", 48 | "node" 49 | ], 50 | "moduleNameMapper": { 51 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/mocks/fileMock.js", 52 | "\\.(s?css|sass)$": "/mocks/styleMock.js" 53 | } 54 | }, 55 | "build": { 56 | "productName": "TOMATOX", 57 | "copyright": "Copyright @ 2021 yanjiaxuan", 58 | "appId": "github.yanjiaxuan.tomatox", 59 | "publish": [ 60 | { 61 | "provider": "github", 62 | "owner": "yanjiaxuan", 63 | "repo": "TOMATOX" 64 | } 65 | ], 66 | "directories": { 67 | "output": "release" 68 | }, 69 | "files": [ 70 | "dist/", 71 | "package.json" 72 | ], 73 | "win": { 74 | "icon": "public/icon256.ico", 75 | "target": [ 76 | { 77 | "target": "nsis", 78 | "arch": [ 79 | "x64" 80 | ] 81 | } 82 | ] 83 | }, 84 | "linux": { 85 | "icon": "public/icon.png" 86 | }, 87 | "mac": { 88 | "icon": "public/icon.icn", 89 | "category": "public.app-category.developer-tools", 90 | "target": "default", 91 | "extendInfo": { 92 | "LSUIElement": 1 93 | } 94 | }, 95 | "snap": { 96 | "publish": [ 97 | "github" 98 | ] 99 | }, 100 | "nsis": { 101 | "installerIcon": "public/icon256.ico", 102 | "installerHeaderIcon": "public/icon256.ico", 103 | "uninstallerIcon": "public/icon256.ico", 104 | "uninstallDisplayName": "TOMATOX", 105 | "oneClick": false, 106 | "allowToChangeInstallationDirectory": true, 107 | "allowElevation": true, 108 | "createDesktopShortcut": true, 109 | "createStartMenuShortcut": true 110 | } 111 | }, 112 | "repository": { 113 | "type": "git", 114 | "url": "git@github.com:yanjiaxuan/TOMATOX.git" 115 | }, 116 | "files": [ 117 | "dist/", 118 | "package.json" 119 | ], 120 | "author": { 121 | "name": "yanjiaxuan", 122 | "email": "330544968@qq.com" 123 | }, 124 | "license": "MIT", 125 | "bugs": { 126 | "url": "https://github.com/yanjiaxuan/TOMATOX/issues" 127 | }, 128 | "homepage": "https://github.com/yanjiaxuan/TOMATOX", 129 | "devDependencies": { 130 | "@babel/core": "7.12.3", 131 | "@babel/plugin-proposal-class-properties": "^7.4.4", 132 | "@babel/polyfill": "^7.4.4", 133 | "@babel/preset-env": "^7.4.5", 134 | "@babel/preset-react": "^7.0.0", 135 | "@babel/preset-typescript": "^7.3.3", 136 | "@babel/runtime": "^7.14.0", 137 | "@hot-loader/react-dom": "^16.8.6", 138 | "@types/electron-devtools-installer": "^2.2.0", 139 | "@types/electron-localshortcut": "^3.1.0", 140 | "@types/jest": "^24.0.13", 141 | "@types/react": "^17.0.5", 142 | "@types/react-dom": "^17.0.5", 143 | "@types/react-infinite-scroller": "^1.2.1", 144 | "@types/react-redux": "^7.0.9", 145 | "@types/react-router-dom": "^5.1.7", 146 | "@types/react-test-renderer": "^16.8.1", 147 | "@types/webdriverio": "^4.8.7", 148 | "@types/webpack-env": "^1.13.3", 149 | "@typescript-eslint/eslint-plugin": "^2.4.0", 150 | "@typescript-eslint/parser": "^2.4.0", 151 | "antd": "^4.15.5", 152 | "axios": "^0.21.1", 153 | "babel-loader": "^8.1.0", 154 | "cheerio": "^1.0.0-rc.9", 155 | "copy-webpack-plugin": "^4.6.0", 156 | "cross-env": "^5.1.3", 157 | "css-loader": "^2.1.1", 158 | "electron": "^3.1.9", 159 | "electron-builder": "^22.3.2", 160 | "electron-devtools-installer": "^2.2.4", 161 | "electron-localshortcut": "^3.2.1", 162 | "electron-proxy-agent": "^1.2.0", 163 | "electron-updater": "^4.3.9", 164 | "eslint": "^6.5.1", 165 | "eslint-config-airbnb": "^18.0.1", 166 | "eslint-config-prettier": "^6.4.0", 167 | "eslint-plugin-import": "^2.18.2", 168 | "eslint-plugin-jsx-a11y": "^6.2.3", 169 | "eslint-plugin-prettier": "^3.1.1", 170 | "eslint-plugin-react": "^7.16.0", 171 | "eslint-plugin-react-hooks": "^1.7.0", 172 | "fast-xml-parser": "^3.19.0", 173 | "file-loader": "^3.0.1", 174 | "fork-ts-checker-webpack-plugin": "^1.3.4", 175 | "html-webpack-plugin": "^3.2.0", 176 | "husky": "^4.2.1", 177 | "interpolate-html-plugin": "^3.0.0", 178 | "jest": "^24.8.0", 179 | "lint-staged": "^10.0.7", 180 | "m3u8-parser": "^4.7.0", 181 | "mini-css-extract-plugin": "^1.6.0", 182 | "prettier": "^1.18.2", 183 | "react": "^17.0.2", 184 | "react-dom": "^17.0.2", 185 | "react-hot-loader": "^4.8.8", 186 | "react-infinite-scroller": "^1.2.4", 187 | "react-keeper": "^2.2.3", 188 | "react-redux": "^7.0.3", 189 | "react-router-dom": "^5.2.0", 190 | "react-test-renderer": "^16.8.6", 191 | "redux": "^4.0.1", 192 | "redux-devtools-extension": "^2.13.5", 193 | "sass": "^1.33.0", 194 | "sass-loader": "^7.1.0", 195 | "socks-proxy-agent": "^5.0.0", 196 | "source-map-loader": "^0.2.4", 197 | "spectron": "^5.0.0", 198 | "style-loader": "^0.23.1", 199 | "svg-url-loader": "^7.1.1", 200 | "ts-jest": "^24.0.2", 201 | "typescript": "^4.2.4", 202 | "url-loader": "^4.1.1", 203 | "webpack": "^4.32.2", 204 | "webpack-cli": "^3.3.2", 205 | "webpack-dev-server": "^3.4.1", 206 | "webpack-merge": "^4.2.1", 207 | "xgplayer": "^2.20.8", 208 | "xgplayer-flv.js": "^2.3.0", 209 | "xgplayer-hls.js": "^2.4.2" 210 | }, 211 | "dependencies": {} 212 | } 213 | -------------------------------------------------------------------------------- /public/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/public/icon.icns -------------------------------------------------------------------------------- /public/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/public/icon.ico -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/public/icon.png -------------------------------------------------------------------------------- /public/icon256.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/public/icon256.ico -------------------------------------------------------------------------------- /public/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FreeIess/TOMATOX/07f12e693303232faaddbedaec459fa3ab4e3a56/public/icon256.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | TOMATOX 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "TOMATOX", 3 | "name": "TOMATOX", 4 | "icons": [ 5 | { 6 | "src": "icon.png", 7 | "sizes": "64x64 32x32 24x24 16x16 192x192 512x512", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/main/auto-update/auto-update.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, remote } from 'electron'; 2 | import { autoUpdater } from 'electron-updater'; 3 | 4 | const delDir = require('../utils/deleteDir'); 5 | const path = require('path'); 6 | 7 | // electron-updater 增量更新时似乎无法显示进度 8 | export function initUpdater(win: BrowserWindow) { 9 | autoUpdater.autoDownload = false; 10 | autoUpdater.autoInstallOnAppQuit = true; 11 | 12 | // fix download error when old version update file already exists 13 | function downloadUpdate() { 14 | autoUpdater.downloadUpdate().catch(err => { 15 | if (err.message && err.message.includes('file already exists') && err.path) { 16 | delDir(err.path); 17 | downloadUpdate(); 18 | } else { 19 | win.webContents.send('update-error', err); 20 | } 21 | }); 22 | } 23 | 24 | // 主进程监听检查更新事件 25 | ipcMain.on('checkForUpdate', () => { 26 | autoUpdater.checkForUpdates().catch(err => { 27 | win.webContents.send('update-error', err); 28 | }); 29 | }); 30 | 31 | // 主进程监听开始下载事件 32 | ipcMain.on('downloadUpdate', () => { 33 | downloadUpdate(); 34 | }); 35 | 36 | // 主进程监听退出并安装事件 37 | ipcMain.on('quitAndInstall', () => { 38 | autoUpdater.quitAndInstall(); 39 | }); 40 | 41 | // 开始检测是否有更新 42 | autoUpdater.on('checking-for-update', () => { 43 | win.webContents.send('checking-for-update'); 44 | }); 45 | 46 | // 检测到有可用的更新 47 | autoUpdater.on('update-available', info => { 48 | win.webContents.send('update-available', info); 49 | }); 50 | 51 | // 没有检测到有可用的更新 52 | autoUpdater.on('update-not-available', () => { 53 | win.webContents.send('update-not-available'); 54 | }); 55 | 56 | // 更新出错 57 | autoUpdater.on('update-error', err => { 58 | win.webContents.send('update-error', err); 59 | }); 60 | 61 | // 下载更新进度 62 | autoUpdater.on('download-progress', progressObj => { 63 | win.webContents.send('download-progress', progressObj); 64 | }); 65 | 66 | // 下载完成 67 | autoUpdater.on('update-downloaded', () => { 68 | win.webContents.send('update-downloaded'); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/main/event-handler/event-handler.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | const {ipcMain} = require('electron') 3 | 4 | module.exports = function eventHandler(mainWindow: Electron.BrowserWindow) { 5 | ipcMain.on('WINDOW_MIN', () => { 6 | mainWindow.minimize() 7 | }) 8 | ipcMain.on('WINDOW_MAX', () => { 9 | if (mainWindow.isMaximized()) { 10 | mainWindow.unmaximize() 11 | } else { 12 | mainWindow.maximize() 13 | } 14 | }) 15 | ipcMain.on('WINDOW_CLOSE', () => { 16 | mainWindow.close() 17 | }) 18 | ipcMain.on('WINDOW_DEBUG', () => { 19 | mainWindow.webContents.openDevTools() 20 | }) 21 | } -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | // 引入electron并创建一个BrowserWindow 2 | import { app, BrowserWindow } from 'electron'; 3 | import { initUpdater } from './auto-update/auto-update'; 4 | 5 | const path = require('path'); 6 | const url = require('url'); 7 | const procEvent = require('./event-handler/event-handler'); 8 | 9 | app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors'); // 允许跨域 10 | app.commandLine.appendSwitch('--ignore-certificate-errors', 'true'); // 忽略证书相关错误 11 | 12 | // 保持window对象的: BrowserWindow | null全局引用,避免JavaScript对象被垃圾回收时,窗口被自动关闭. 13 | let mainWindow: Electron.BrowserWindow | null; 14 | 15 | function createWindow() { 16 | // 创建浏览器窗口,宽高自定义具体大小你开心就好 17 | mainWindow = new BrowserWindow({ 18 | show: false, 19 | width: 1260, 20 | height: 700, 21 | minWidth: 1260, 22 | minHeight: 700, 23 | frame: false, 24 | backgroundColor: '#403f3f', 25 | webPreferences: { 26 | webSecurity: false, 27 | // nodeIntegration: true, 28 | contextIsolation: false 29 | } 30 | }); 31 | procEvent(mainWindow); 32 | 33 | if (process.env.NODE_ENV !== 'production') { 34 | process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = '1'; // eslint-disable-line require-atomic-updates 35 | mainWindow!.loadURL(`http://localhost:2003`); 36 | } else { 37 | mainWindow!.loadURL( 38 | url.format({ 39 | pathname: path.join(__dirname, 'index.html'), 40 | protocol: 'file:', 41 | slashes: true 42 | }) 43 | ); 44 | } 45 | mainWindow.on('ready-to-show', () => { 46 | mainWindow?.show(); 47 | }); 48 | initUpdater(mainWindow); 49 | } 50 | 51 | // 当 Electron 完成初始化并准备创建浏览器窗口时调用此方法 52 | app.on('ready', createWindow); 53 | // 所有窗口关闭时退出应用. 54 | app.on('window-all-closed', () => { 55 | // macOS中除非用户按下 `Cmd + Q` 显式退出,否则应用与菜单栏始终处于活动状态. 56 | if (process.platform !== 'darwin') { 57 | app.quit(); 58 | } 59 | }); 60 | app.on('activate', () => { 61 | // macOS中点击Dock图标时没有已打开的其余应用窗口时,则通常在应用中重建一个窗口 62 | if (mainWindow === null) { 63 | createWindow(); 64 | } 65 | }); 66 | // 你可以在这个脚本中续写或者使用require引入独立的js文件. 67 | -------------------------------------------------------------------------------- /src/main/utils/deleteDir.ts: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | function delDir(path: string) { 4 | if (fs.existsSync(path)) { 5 | const files = fs.readdirSync(path); 6 | files.forEach((file: string) => { 7 | const curPath = `${path}/${file}`; 8 | if (fs.statSync(curPath).isDirectory()) { 9 | delDir(curPath); 10 | } else { 11 | fs.unlinkSync(curPath); 12 | } 13 | }); 14 | fs.rmdirSync(path); 15 | } 16 | } 17 | 18 | module.exports = delDir; 19 | -------------------------------------------------------------------------------- /src/renderer/app.css: -------------------------------------------------------------------------------- 1 | @import "~antd/dist/antd.css"; 2 | @import "./styles/overwrite.css"; 3 | html { 4 | overflow: hidden; 5 | } 6 | body { 7 | margin: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 17 | monospace; 18 | } -------------------------------------------------------------------------------- /src/renderer/app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import CustomLayout from './components/layout/custom-layout'; 4 | import './app.css'; 5 | import Theme from '@/components/theme/theme'; 6 | 7 | ReactDOM.render( 8 | <> 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | -------------------------------------------------------------------------------- /src/renderer/components/custom-spin/custom-spin.scss: -------------------------------------------------------------------------------- 1 | .loading-icon-wrapper { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | margin: -10px; 6 | transform: translate(-50%, -50%); 7 | } 8 | 9 | .loading-icon { 10 | animation: icon-gun 1s ease-in-out 0s infinite alternate; 11 | } 12 | 13 | @keyframes icon-gun { 14 | 0% { 15 | transform: translate(-40px) rotate(0deg); 16 | } 17 | 100% { 18 | transform: translate(40px) rotate(360deg); 19 | } 20 | } 21 | 22 | .loading-text { 23 | letter-spacing: 1px; 24 | animation: text-gun 1s ease-in-out 0s infinite alternate; 25 | } 26 | 27 | @keyframes text-gun { 28 | 0% { 29 | transform: rotate(-15deg) translateY(-10px); 30 | } 31 | 50% { 32 | transform: rotate(0deg) translateY(0px); 33 | } 34 | 100% { 35 | transform: rotate(15deg) translateY(-10px); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/components/custom-spin/custom-spin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from '@/images/svg/icon.svg'; 3 | import cssM from './custom-spin.scss'; 4 | 5 | export default function CustomSpin() { 6 | return ( 7 | 8 | 9 |
Loading...
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/components/global-loading/global-loading.scss: -------------------------------------------------------------------------------- 1 | .global-loading-wrapper { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | .loading-icon { 9 | animation: g-loading-ani 0.5s 0s cubic-bezier(0, 0, 0.49, 0.91) infinite alternate; 10 | } 11 | .loading-text { 12 | font-size: 25px; 13 | font-weight: bold; 14 | } 15 | } 16 | 17 | @keyframes g-loading-ani { 18 | 0% { 19 | transform: translateY(0px) scale(1, 0.9); 20 | } 21 | 100% { 22 | transform: translateY(-100px) scale(1, 1.1); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/components/global-loading/global-loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cssM from './global-loading.scss'; 3 | import logo from '@/images/svg/icon.svg'; 4 | 5 | export default function() { 6 | return ( 7 |
8 | 9 |
TOMATOX
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/components/layout/content/custom-content.scss: -------------------------------------------------------------------------------- 1 | .content-wrapper { 2 | width: 100%; 3 | height: calc(100vh - 50px); 4 | overflow-y: hidden; 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/components/layout/content/custom-content.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Recommend from '@/views/recommend/recommend'; 3 | import Classify from '@/views/classify/classify'; 4 | import History from '@/views/history/history'; 5 | import Collect from '@/views/collect/collect'; 6 | import Player from '@/views/player/player'; 7 | import { Spin } from 'antd'; 8 | import CustomSpin from '@/components/custom-spin/custom-spin'; 9 | import store from '@/utils/store'; 10 | import Developing from '@/views/developing/developing'; 11 | import { Route } from 'react-keeper'; 12 | import Search from '@/views/search/search'; 13 | import cssM from './custom-content.scss'; 14 | import About from '@/views/about/about'; 15 | import Iptv from '@/views/iptv/iptv'; 16 | import IptvPlayer from '@/views/iptv/iptv-player/iptv-player'; 17 | import Setting from '@/views/setting/setting'; 18 | 19 | function updatePath(cb: Function, props: any) { 20 | store.setState('CURRENT_PATH', props.path); 21 | cb(); 22 | } 23 | 24 | export default function customContent() { 25 | const [load, setLoading] = useState(store.getState('GLOBAL_LOADING')); 26 | useEffect(() => { 27 | return store.subscribe('GLOBAL_LOADING', (val: boolean) => { 28 | setLoading(val); 29 | }); 30 | }); 31 | return ( 32 | } spinning={load}> 33 |
34 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer/components/layout/custom-layout.scss: -------------------------------------------------------------------------------- 1 | .full-content { 2 | width: 100%; 3 | height: 100vh; 4 | } 5 | 6 | .custom-header { 7 | height: 50px; 8 | line-height: 50px; 9 | //background-color: #4a4a4a; 10 | -webkit-app-region: drag; 11 | } 12 | 13 | .custom-content { 14 | } 15 | 16 | .default-background { 17 | width: 100%; 18 | height: 100vh; 19 | background-color: #403f3f; 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/components/layout/custom-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from 'antd'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { HashRouter } from 'react-keeper'; 4 | import Indexed from '@/utils/db/indexed'; 5 | import cssM from './custom-layout.scss'; 6 | import CustomSider from './sider/custom-sider'; 7 | import CustomHeader from './header/custom-header'; 8 | import CustomContent from './content/custom-content'; 9 | import { TABLES } from '@/utils/constants'; 10 | import { getEnabledOrigin, getTheme } from '@/utils/db/storage'; 11 | import store from '@/utils/store'; 12 | import GlobalLoading from '../global-loading/global-loading'; 13 | 14 | const { Header, Sider, Content } = Layout; 15 | 16 | export default function CustomLayout() { 17 | const [loaded, setLoaded] = useState(false); 18 | if (!loaded) { 19 | Indexed.init().then(async () => { 20 | const origin = await Indexed.instance!.queryById( 21 | TABLES.TABLE_ORIGIN, 22 | getEnabledOrigin() 23 | ); 24 | store.setState('SITE_ADDRESS', origin); 25 | setLoaded(true); 26 | }); 27 | } 28 | return ( 29 | <> 30 | {loaded ? ( 31 | 32 | ) : ( 33 |
34 | {/* */} 35 |
36 | )} 37 | 38 | ); 39 | } 40 | 41 | function LayoutFunc() { 42 | const [theme, setTheme] = useState(getTheme()); 43 | useEffect(() => { 44 | return store.subscribe('TOMATOX_THEME', (val: any) => { 45 | setTheme(val); 46 | }); 47 | }); 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | 55 |
57 | 58 |
59 | 60 | 61 | 62 |
63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/components/layout/header/custom-header.scss: -------------------------------------------------------------------------------- 1 | .header-wrapper { 2 | display: flex; 3 | flex-wrap: nowrap; 4 | justify-content: space-between; 5 | align-items: center; 6 | } 7 | 8 | .header-input { 9 | min-width: 300px; 10 | max-width: 450px; 11 | margin-left: 100px; 12 | -webkit-app-region: no-drag; 13 | } 14 | 15 | .operation-btn { 16 | -webkit-app-region: no-drag; 17 | span { 18 | padding: 0 5px; 19 | cursor: pointer; 20 | transition: all 0.3s ease-in-out; 21 | width: 30px; 22 | font-size: 15px; 23 | &:hover { 24 | color: #f1f1f1; 25 | } 26 | } 27 | } 28 | 29 | .app-btn { 30 | -webkit-app-region: no-drag; 31 | span { 32 | cursor: pointer; 33 | transition: all 0.3s ease-in-out; 34 | width: 30px; 35 | display: inline-block; 36 | font-size: 20px; 37 | margin: 0 5px; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/components/layout/header/custom-header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Input, message } from 'antd'; 3 | import { 4 | BugOutlined, 5 | ReloadOutlined, 6 | SearchOutlined, 7 | LeftOutlined, 8 | MinusOutlined, 9 | BlockOutlined, 10 | CloseOutlined, 11 | UserOutlined, 12 | SkinOutlined, 13 | ShareAltOutlined 14 | } from '@ant-design/icons'; 15 | import { Control } from 'react-keeper'; 16 | import store from '@/utils/store'; 17 | import cssModule from './custom-header.scss'; 18 | import { setTheme } from '@/utils/db/storage'; 19 | 20 | const { ipcRenderer } = require('electron'); 21 | 22 | function developingMsg() { 23 | message.info({ 24 | content: '功能正在开发中...', 25 | className: cssModule.msgClass, 26 | icon: <>, 27 | duration: 1 28 | }); 29 | } 30 | 31 | function changeTheme() { 32 | const targetTheme = store.getState('TOMATOX_THEME') === 'dark' ? 'light' : 'dark'; 33 | store.setState('TOMATOX_THEME', targetTheme); 34 | setTheme(targetTheme); 35 | } 36 | 37 | export default function CustomHeader() { 38 | const [searchEnable, setSearchEnable] = useState(store.getState('GLOBAL_SEARCH_ENABLE')); 39 | store.subscribe('GLOBAL_SEARCH_ENABLE', (newVal: boolean) => { 40 | setSearchEnable(newVal); 41 | }); 42 | async function onSearch(keyword: string) { 43 | store.setState('GLOBAL_SEARCH_ENABLE', false); 44 | store.setState('SEARCH_KEYWORDS', keyword); 45 | Control.go('/search'); 46 | } 47 | return ( 48 |
49 | 55 | 全网搜 56 | 57 | } 58 | className={cssModule.headerInput} 59 | /> 60 | 61 | {process.env.NODE_ENV !== 'production' && ( 62 | { 64 | ipcRenderer.send('WINDOW_DEBUG'); 65 | }} 66 | /> 67 | )} 68 | {process.env.NODE_ENV !== 'production' && ( 69 | { 71 | window.location.href = '/'; 72 | }} 73 | style={{ fontSize: 18 }} 74 | /> 75 | )} 76 | 77 | 78 | 79 | 80 | 81 | { 83 | ipcRenderer.send('WINDOW_MIN'); 84 | }} 85 | /> 86 | { 88 | ipcRenderer.send('WINDOW_MAX'); 89 | }} 90 | /> 91 | { 93 | ipcRenderer.send('WINDOW_CLOSE'); 94 | }} 95 | /> 96 | 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/renderer/components/layout/sider/custom-sider.scss: -------------------------------------------------------------------------------- 1 | .prod-title { 2 | position: fixed; 3 | top: 0; 4 | width: 170px; 5 | display: flex; 6 | align-items: center; 7 | padding: 10px; 8 | font-size: 16px; 9 | font-weight: bold; 10 | -webkit-app-region: drag; 11 | } 12 | 13 | .prod-icon { 14 | width: 30px; 15 | margin-right: 10px; 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/components/layout/sider/custom-sider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Menu } from 'antd'; 3 | import { 4 | FireOutlined, 5 | HistoryOutlined, 6 | HeartOutlined, 7 | AppstoreOutlined, 8 | SearchOutlined, 9 | StarOutlined, 10 | PlayCircleOutlined, 11 | SettingOutlined 12 | } from '@ant-design/icons'; 13 | import { Link } from 'react-keeper'; 14 | import Icon from '@/images/svg/icon.svg'; 15 | import store from '@/utils/store'; 16 | import cssM from './custom-sider.scss'; 17 | 18 | export default function CustomSider(props: any) { 19 | const [path, setPath] = useState(window.location.hash.slice(1)); 20 | store.subscribe('CURRENT_PATH', (newPath: string) => { 21 | setPath(newPath); 22 | }); 23 | 24 | return ( 25 | <> 26 |
27 | 28 | TOMATOX 29 |
30 | 31 | }> 32 | 33 | 推荐 34 | 35 | 36 | }> 37 | 38 | 分类 39 | 40 | 41 | }> 42 | 43 | 直播 44 | 45 | 46 | }> 47 | 48 | 搜索 49 | 50 | 51 | }> 52 | 53 | 历史 54 | 55 | 56 | }> 57 | 58 | 收藏 59 | 60 | 61 | }> 62 | 63 | 设置 64 | 65 | 66 | }> 67 | 68 | 关于 69 | 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/components/theme/theme.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import store from '@/utils/store'; 3 | import { getTheme } from '@/utils/db/storage'; 4 | 5 | const themeMap = { 6 | dark: { 7 | headerBG: '#4a4a4a', 8 | header2ndBG: '#444444', 9 | color: '#f1f1f1', 10 | headerInputBG: '#696666', 11 | placeholderColor: '#adadad', 12 | logoBG: '#3a3a3a', 13 | contentBG: '#403f3f' 14 | }, 15 | light: { 16 | headerBG: '#e8e8e8', 17 | header2ndBG: '#efefef', 18 | color: '#525252', 19 | headerInputBG: '#dcdcdc', 20 | placeholderColor: '#787878', 21 | logoBG: '#dedede', 22 | contentBG: '#f9f9f9' 23 | } 24 | }; 25 | 26 | export default function theme() { 27 | const [curTheme, setCurTheme] = useState<'dark' | 'light'>(getTheme()); 28 | 29 | useEffect(() => { 30 | return store.subscribe('TOMATOX_THEME', (val: any) => { 31 | setCurTheme(val); 32 | }); 33 | }); 34 | 35 | return ( 36 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/components/tomatox-waterfall/tomatox-waterfall.scss: -------------------------------------------------------------------------------- 1 | .card-list { 2 | display: grid; 3 | padding: 20px 10px 0 10px; 4 | grid-template-columns: repeat(auto-fill, 150px); 5 | grid-column-gap: 25px; 6 | grid-row-gap: 15px; 7 | justify-content: center; 8 | } 9 | 10 | .card { 11 | position: relative; 12 | display: flex; 13 | flex-direction: column; 14 | margin-bottom: 5px; 15 | & > span { 16 | width: 150px; 17 | text-overflow: ellipsis; 18 | overflow: hidden; 19 | white-space: nowrap; 20 | } 21 | & > span:nth-of-type(1) { 22 | margin-top: 3px; 23 | font-size: 14px; 24 | } 25 | & > span:nth-of-type(2) { 26 | font-size: 12px; 27 | } 28 | & > span:nth-of-type(3) { 29 | font-size: 12px; 30 | } 31 | } 32 | 33 | .top-right-title { 34 | position: absolute; 35 | left: 5px; 36 | top: 5px; 37 | font-size: 13px; 38 | padding: 1px 3px; 39 | border-radius: 3px; 40 | background-color: #fc904b; 41 | color: #fff; 42 | } 43 | 44 | .desc-img { 45 | width: 150px; 46 | height: 220px; 47 | border-radius: 5px; 48 | object-fit: cover; 49 | } 50 | 51 | .resource-collect { 52 | position: absolute; 53 | right: 7px; 54 | top: 195px; 55 | cursor: pointer; 56 | color: #ff5c49 !important; 57 | font-size: 18px; 58 | transition: all 0.2s linear; 59 | &:hover { 60 | font-size: 22px; 61 | right: 5px; 62 | top: 193px; 63 | } 64 | } 65 | .resource-not-collect { 66 | position: absolute; 67 | right: 7px; 68 | top: 195px; 69 | cursor: pointer; 70 | font-size: 18px; 71 | transition: all 0.2s linear; 72 | color: #fff !important; 73 | &:hover { 74 | font-size: 22px; 75 | right: 5px; 76 | top: 193px; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/renderer/components/tomatox-waterfall/tomatox-waterfall.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Link } from 'react-keeper'; 3 | import TOMATOX_ICON from '@/images/svg/icon.svg'; 4 | import { HeartOutlined, HeartFilled } from '@ant-design/icons'; 5 | import Indexed from '@/utils/db/indexed'; 6 | import { TABLES } from '@/utils/constants'; 7 | import cssM from './tomatox-waterfall.scss'; 8 | 9 | export default function tomatoxWaterfall(props: { data: IplayResource[] }) { 10 | const [collectRes, setCollectRes] = useState(Indexed.collectedRes); 11 | const cardsData = props.data; 12 | function convertEle() { 13 | const res = []; 14 | for (const ele of cardsData) { 15 | res.push( 16 | 17 | 18 |
19 |
20 | 21 | {ele.remark} 22 |
23 | {collectRes.has(ele.id) ? ( 24 | { 27 | Indexed.instance?.cancelCollect(ele.id); 28 | setCollectRes(new Set(Indexed.collectedRes)); 29 | e.stopPropagation(); 30 | e.preventDefault(); 31 | }} 32 | /> 33 | ) : ( 34 | { 37 | Indexed.instance?.doCollect(ele); 38 | setCollectRes(new Set(Indexed.collectedRes)); 39 | e.stopPropagation(); 40 | e.preventDefault(); 41 | }} 42 | /> 43 | )} 44 |
45 |
46 | {ele.name} 47 | 48 | {ele.historyOption?.lastPlayDesc ? '' : ele.actor || '未知'} 49 | 50 | {ele.historyOption?.lastPlayDesc && ( 51 | 52 | {ele.historyOption.lastPlayDesc} 53 | 54 | )} 55 |
56 | 57 |
58 | ); 59 | } 60 | return res; 61 | } 62 | return
{convertEle()}
; 63 | } 64 | -------------------------------------------------------------------------------- /src/renderer/images/svg/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/styles/overwrite.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: tahoma, 'microsoft yahei', '\5FAE\8F6F\96C5\9ED1', 'sans-serif' !important; 3 | user-select: none; 4 | --antd-wave-shadow-color: #ff5c49; 5 | } 6 | 7 | /*滚动条整体宽度*/ 8 | ::-webkit-scrollbar { 9 | width: 7px; 10 | } 11 | /*滚动条滑槽样式*/ 12 | ::-webkit-scrollbar-track { 13 | /*-webkit-box-shadow: inset 0 0 6px rgba(9, 9, 9, 0.71);*/ 14 | /*border-radius: 1px;*/ 15 | } 16 | /*滚动条样式*/ 17 | ::-webkit-scrollbar-thumb { 18 | /*border-radius: 2px;*/ 19 | background: rgba(111, 111, 111, 0.78); 20 | 21 | /*-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.5);*/ 22 | } 23 | ::-webkit-scrollbar-thumb:hover { 24 | /*background: #ccc;*/ 25 | } 26 | ::-webkit-scrollbar-thumb:active { 27 | /*background: #999;*/ 28 | } 29 | /*浏览器失焦的样式*/ 30 | ::-webkit-scrollbar-thumb:window-inactive { 31 | /*background: #ffffff;*/ 32 | } 33 | 34 | .ant-layout-sider-children { 35 | margin-top: 50px; 36 | height: calc(100vh - 50px); 37 | overflow-y: auto; 38 | } 39 | 40 | .ant-layout-header { 41 | padding: 0 10px; 42 | background-color: #4a4a4a; 43 | height: 50px; 44 | line-height: 50px; 45 | } 46 | 47 | .ant-layout-sider-dark { 48 | background-color: #333333; 49 | } 50 | 51 | .ant-menu-dark { 52 | background-color: #333333 !important; 53 | } 54 | 55 | .ant-spin-nested-loading > div > .ant-spin { 56 | max-height: unset; 57 | } 58 | 59 | .ant-tabs-tab + .ant-tabs-tab { 60 | margin: 0 0 0 15px; 61 | } 62 | 63 | .ant-tabs-tab-btn { 64 | color: #ffffff; 65 | font-size: 13px; 66 | } 67 | 68 | .ant-tabs-tab-btn:focus, 69 | .ant-tabs-tab-remove:focus, 70 | .ant-tabs-tab-btn:active, 71 | .ant-tabs-tab-remove:active { 72 | color: #ff5c49; 73 | } 74 | 75 | .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { 76 | color: #ff5c49; 77 | } 78 | 79 | .ant-tabs-ink-bar { 80 | background: #ff5c49; 81 | } 82 | 83 | .ant-menu-item-selected { 84 | color: #fff !important; 85 | background: #ff5c49 !important; 86 | } 87 | 88 | .ant-menu-item-selected a, 89 | .ant-menu-item-selected a:hover { 90 | color: #fff !important; 91 | } 92 | .ant-menu-vertical .ant-menu-item::after, 93 | .ant-menu-vertical-left .ant-menu-item::after, 94 | .ant-menu-vertical-right .ant-menu-item::after, 95 | .ant-menu-inline .ant-menu-item::after { 96 | border-right: unset; 97 | } 98 | 99 | .ant-btn-primary { 100 | background: #ff5c49; 101 | border-color: #ff5c49; 102 | } 103 | 104 | .ant-btn-primary:hover { 105 | background: #ff5c49; 106 | border-color: #ff5c49; 107 | } 108 | 109 | .ant-btn-primary:focus { 110 | background: #ff5c49; 111 | border-color: #ff5c49; 112 | } 113 | 114 | .ant-input { 115 | color: #fff; 116 | } 117 | 118 | .ant-input:hover { 119 | border-color: #ff5c49 !important; 120 | } 121 | .ant-input:focus { 122 | border-color: #ff5c49 !important; 123 | } 124 | 125 | .ant-spin { 126 | color: #403f3f; 127 | transition: unset !important; 128 | } 129 | 130 | .ant-spin-blur { 131 | opacity: 0.1 !important; 132 | } 133 | .ant-spin-container { 134 | transition: unset; 135 | } 136 | .ant-spin-container::after { 137 | transition: unset !important; 138 | } 139 | 140 | .ant-tabs { 141 | color: #fff; 142 | } 143 | 144 | .ant-tabs-tab { 145 | padding: 0 0 5px 0; 146 | } 147 | 148 | .ant-tabs-nav { 149 | margin: 0 0 10px 0 !important; 150 | } 151 | 152 | .ant-tabs-nav::before { 153 | border-bottom: unset !important; 154 | } 155 | 156 | .ant-message-notice-content { 157 | background: #484848; 158 | color: #f39856; 159 | } 160 | 161 | .ant-radio-wrapper { 162 | color: #f1f1f1; 163 | } 164 | 165 | .ant-radio-inner::after { 166 | background-color: #ff5c49; 167 | } 168 | 169 | .ant-radio-checked .ant-radio-inner { 170 | border-color: #ff5c49; 171 | } 172 | 173 | .ant-radio-wrapper:hover .ant-radio, 174 | .ant-radio:hover .ant-radio-inner, 175 | .ant-radio-input:focus + .ant-radio-inner { 176 | border-color: #ff5c49; 177 | } 178 | 179 | .ant-checkbox-wrapper + .ant-checkbox-wrapper { 180 | margin-left: unset; 181 | } 182 | 183 | .ant-checkbox-checked .ant-checkbox-inner { 184 | background-color: #ff5c49; 185 | border-color: #ff5c49; 186 | } 187 | 188 | .ant-checkbox-checked::after { 189 | border: 1px solid #ff5c49; 190 | } 191 | 192 | .ant-checkbox-wrapper:hover .ant-checkbox-inner, 193 | .ant-checkbox:hover .ant-checkbox-inner, 194 | .ant-checkbox-input:focus + .ant-checkbox-inner { 195 | border-color: #ff5c49; 196 | } 197 | 198 | .ant-input-group-addon { 199 | background-color: #ff5c49; 200 | color: #f1f1f1; 201 | border: 1px solid #ff5c49; 202 | } 203 | 204 | .ant-input { 205 | background-color: #696666; 206 | border-color: #696666; 207 | } 208 | 209 | .ant-btn { 210 | background-color: #ff5c49; 211 | color: #f1f1f1; 212 | border: 1px solid #ff5c49; 213 | } 214 | 215 | .ant-btn:hover, 216 | .ant-btn:focus { 217 | background-color: #ff5c49; 218 | color: #f1f1f1; 219 | border: 1px solid #ff5c49; 220 | } 221 | 222 | .ant-tabs-content-holder { 223 | height: 100%; 224 | overflow-y: auto; 225 | } 226 | -------------------------------------------------------------------------------- /src/renderer/typing/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg'; 2 | declare module '*.scss'; 3 | 4 | declare interface Iorigin { 5 | id: string; 6 | api: string; 7 | addTime: number; 8 | } 9 | 10 | declare interface IplayResource { 11 | id: string; 12 | type: string; 13 | picture: string; 14 | lang: string; 15 | name: string; 16 | director: string; 17 | describe: string; 18 | area: string; 19 | actor: string; 20 | class: string; 21 | doubanId: string; 22 | doubanScore: string; 23 | origin: string; 24 | remark: string; 25 | tag: string; 26 | year: string; 27 | updateTime: string; 28 | playList: Map; 29 | historyOption?: { 30 | lastPlaySrc?: string; 31 | lastPlayTime?: number; 32 | lastPlayDate?: number; 33 | lastPlayDrama?: string; 34 | lastPlayDesc?: string; 35 | }; 36 | collectOption?: { 37 | collectDate?: number; 38 | }; 39 | } 40 | 41 | declare interface IplayConfig { 42 | voice?: number; 43 | speed?: number; 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_ORIGIN: Iorigin = { 2 | id: '默认', 3 | api: 'https://www.kuaibozy.com/api.php/provide/vod/from/kbm3u8/at/xml', 4 | addTime: Date.now() 5 | }; 6 | export const CANDIDATE_ORIGIN: Iorigin = { 7 | id: '百度云资源', 8 | api: 'https://m3u8.apibdzy.com/api.php/provide/vod/at/xml', 9 | addTime: Date.now() + 1 10 | }; 11 | export const DEFAULT_SEARCH_INDEX = 'https://gitee.com/yanjiaxuan/TOMATOX_RES/raw/main/result.json'; 12 | export const defaultIndexMapper: Record = {}; 13 | fetch(DEFAULT_SEARCH_INDEX) 14 | .then(res => res.json()) 15 | .then(res => { 16 | for (const key in res) { 17 | defaultIndexMapper[key] = res[key]; 18 | } 19 | }); 20 | 21 | export const IPTV_ORIGIN_URL = 'https://gitee.com/yanjiaxuan/TOMATOX_RES/raw/main/zhibo.json'; 22 | export const PROD_STATEMENT = 23 | '版权声明:本人发布的所有资源或软件均来自网络,与本人没有任何关系,只能作为私下交流、学习、研究之用,版权归原作者及原软件公司所有。\n' + 24 | ' 本人发布的所有资源或软件请在下载后24小时内自行删除。如果您喜欢这个资源或软件,请联系原作者或原软件公司购买正版。与本人无关!\n' + 25 | ' 本人仅仅提供一个私下交流、学习、研究的环境,将不对任何资源或软件负法律责任!\n' + 26 | ' 任何涉及商业盈利性目的的单位或个人,均不得使用本人发布的资源或软件,否则产生的一切后果将由使用者自己承担!'; 27 | 28 | export const TABLES = { 29 | TABLE_HISTORY: 'tomatox_play_history', 30 | TABLE_COLLECT: 'tomatox_collect', 31 | TABLE_ORIGIN: 'tomatox_origin' 32 | }; 33 | -------------------------------------------------------------------------------- /src/renderer/utils/db/indexed.ts: -------------------------------------------------------------------------------- 1 | import { CANDIDATE_ORIGIN, DEFAULT_ORIGIN, TABLES } from '@/utils/constants'; 2 | import { cleanResourceData } from '@/utils/filterResources'; 3 | import { setEnabledOrigin } from '@/utils/db/storage'; 4 | 5 | export default class Indexed { 6 | private static db: IDBDatabase | undefined; 7 | public static instance: Indexed | undefined; 8 | static collectedRes: Set = new Set(); 9 | 10 | private constructor() { 11 | // do nothing 12 | } 13 | 14 | public static init(): Promise { 15 | return new Promise((resolve, reject) => { 16 | if (!this.instance) { 17 | const dbReq = window.indexedDB.open('TOMATOX', 5); 18 | dbReq.onupgradeneeded = () => { 19 | const db = dbReq.result; 20 | if (!db.objectStoreNames.contains(TABLES.TABLE_HISTORY)) { 21 | const table = db.createObjectStore(TABLES.TABLE_HISTORY, { keyPath: 'id' }); 22 | table.createIndex('lastPlayDate', 'lastPlayDate', { unique: false }); 23 | } 24 | if (!db.objectStoreNames.contains(TABLES.TABLE_COLLECT)) { 25 | db.createObjectStore(TABLES.TABLE_COLLECT, { keyPath: 'id' }); 26 | } 27 | if (!db.objectStoreNames.contains(TABLES.TABLE_ORIGIN)) { 28 | const table = db.createObjectStore(TABLES.TABLE_ORIGIN, { keyPath: 'id' }); 29 | table.put(DEFAULT_ORIGIN); 30 | table.put(CANDIDATE_ORIGIN); 31 | } else { 32 | db.deleteObjectStore(TABLES.TABLE_ORIGIN); 33 | const table = db.createObjectStore(TABLES.TABLE_ORIGIN, { keyPath: 'id' }); 34 | table.put(DEFAULT_ORIGIN); 35 | table.put(CANDIDATE_ORIGIN); 36 | setEnabledOrigin('默认'); 37 | } 38 | }; 39 | dbReq.onsuccess = () => { 40 | this.db = dbReq.result; 41 | this.instance = new Indexed(); 42 | this.instance.removeThreeMonthAgoHistoryData(); 43 | this.instance.loadCollectedRes(); 44 | resolve(this.instance!); 45 | }; 46 | } else { 47 | resolve(this.instance); 48 | } 49 | }); 50 | } 51 | 52 | public queryById(tableName: string, id: any) { 53 | return new Promise(resolve => { 54 | const req = Indexed.db!.transaction(tableName, 'readonly') 55 | .objectStore(tableName) 56 | .get(id); 57 | req.onsuccess = () => { 58 | resolve(req.result); 59 | }; 60 | }); 61 | } 62 | 63 | public queryAll(tableName: string) { 64 | return new Promise(resolve => { 65 | const req = Indexed.db!.transaction(tableName, 'readonly') 66 | .objectStore(tableName) 67 | .getAll(); 68 | req.onsuccess = () => { 69 | resolve(req.result); 70 | }; 71 | }); 72 | } 73 | 74 | public queryAllKeys(tableName: string) { 75 | return new Promise(resolve => { 76 | const req = Indexed.db!.transaction(tableName, 'readonly') 77 | .objectStore(tableName) 78 | .getAllKeys(); 79 | req.onsuccess = () => { 80 | resolve(req.result); 81 | }; 82 | }); 83 | } 84 | 85 | public insertOrUpdateOrigin(tableName: string, data: Iorigin) { 86 | return new Promise(resolve => { 87 | Indexed.db!.transaction(tableName, 'readwrite') 88 | .objectStore(tableName) 89 | .put(data).onsuccess = () => { 90 | resolve(null); 91 | }; 92 | }); 93 | } 94 | public insertOrUpdateResource(tableName: string, data: IplayResource) { 95 | if (tableName === TABLES.TABLE_COLLECT) { 96 | Indexed.collectedRes.add(data.id); 97 | } 98 | const optData: IplayResource = cleanResourceData(tableName, data); 99 | return new Promise(resolve => { 100 | Indexed.db!.transaction(tableName, 'readwrite') 101 | .objectStore(tableName) 102 | .put(optData).onsuccess = () => { 103 | resolve(null); 104 | }; 105 | }); 106 | } 107 | 108 | public deleteById(tableName: string, id: any) { 109 | if (tableName === TABLES.TABLE_COLLECT) { 110 | Indexed.collectedRes.delete(id); 111 | } 112 | return new Promise(resolve => { 113 | Indexed.db!.transaction(tableName, 'readwrite') 114 | .objectStore(tableName) 115 | .delete(id).onsuccess = () => { 116 | resolve(null); 117 | }; 118 | }); 119 | } 120 | 121 | public deleteAll(tableName: string) { 122 | return new Promise(resolve => { 123 | const keyReq = Indexed.db!.transaction(tableName, 'readwrite') 124 | .objectStore(tableName) 125 | .getAllKeys(); 126 | keyReq.onsuccess = async () => { 127 | for (const key of keyReq.result) { 128 | await this.deleteById(tableName, key); 129 | } 130 | resolve(null); 131 | }; 132 | }); 133 | } 134 | 135 | public doCollect(data: IplayResource) { 136 | this.insertOrUpdateResource(TABLES.TABLE_COLLECT, { 137 | ...data, 138 | collectOption: { collectDate: Date.now() } 139 | }); 140 | } 141 | public cancelCollect(id: string) { 142 | this.deleteById(TABLES.TABLE_COLLECT, id); 143 | } 144 | 145 | private removeThreeMonthAgoHistoryData() { 146 | const req = Indexed.db!.transaction(TABLES.TABLE_HISTORY, 'readwrite') 147 | .objectStore(TABLES.TABLE_HISTORY) 148 | .index('lastPlayDate') 149 | .getAllKeys(IDBKeyRange.upperBound(Date.now() - 90 * 24 * 3600000)); 150 | req.onsuccess = res => { 151 | req.result.forEach(key => { 152 | this.deleteById(TABLES.TABLE_HISTORY, key); 153 | }); 154 | }; 155 | } 156 | 157 | private loadCollectedRes() { 158 | this.queryAllKeys(TABLES.TABLE_COLLECT).then(res => { 159 | (res as string[]).forEach(item => { 160 | Indexed.collectedRes.add(item); 161 | }); 162 | }); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/renderer/utils/db/storage.ts: -------------------------------------------------------------------------------- 1 | const configName = 'tomatox_play_config'; 2 | const enabledOriginName = 'tomatox_enabled_origin'; 3 | const tomatoxThemeName = 'tomatox_theme'; 4 | let playConfig: IplayConfig = { 5 | voice: 0.7, 6 | speed: 1 7 | }; 8 | let enabledOrigin: string | null = localStorage.getItem(enabledOriginName); 9 | let tomatoxTheme = localStorage.getItem(tomatoxThemeName); 10 | if (!tomatoxTheme) { 11 | tomatoxTheme = 'dark'; 12 | localStorage.setItem(tomatoxThemeName, tomatoxTheme); 13 | } 14 | 15 | if (!enabledOrigin) { 16 | enabledOrigin = '默认'; 17 | setEnabledOrigin(enabledOrigin); 18 | } 19 | 20 | const localConf = localStorage.getItem(configName); 21 | if (!localConf) { 22 | localStorage.setItem(configName, JSON.stringify(playConfig)); 23 | } 24 | playConfig = { 25 | ...playConfig, 26 | ...JSON.parse(localStorage.getItem(configName)!) 27 | }; 28 | 29 | export function getPlayConfig(): IplayConfig { 30 | return playConfig; 31 | } 32 | 33 | export function setPlayConfig(config: IplayConfig) { 34 | playConfig = { 35 | ...playConfig, 36 | ...config 37 | }; 38 | localStorage.setItem(configName, JSON.stringify(playConfig)); 39 | } 40 | 41 | export function getEnabledOrigin() { 42 | return enabledOrigin; 43 | } 44 | 45 | export function setEnabledOrigin(id: string) { 46 | enabledOrigin = id; 47 | localStorage.setItem(enabledOriginName, id); 48 | } 49 | 50 | export function getTheme(): any { 51 | return tomatoxTheme; 52 | } 53 | 54 | export function setTheme(theme: string) { 55 | tomatoxTheme = theme; 56 | localStorage.setItem(tomatoxThemeName, theme); 57 | } 58 | -------------------------------------------------------------------------------- /src/renderer/utils/filterResources.ts: -------------------------------------------------------------------------------- 1 | import { TABLES } from '@/utils/constants'; 2 | 3 | export function filterResources(resources: any[]) { 4 | return resources.map(res => filterResource(res)); 5 | } 6 | 7 | export function filterResource(resource: any): IplayResource { 8 | let listStr = ''; 9 | if (resource.dl && resource.dl.dd) { 10 | if (resource.dl.dd instanceof Array) { 11 | const videoList = resource.dl.dd.filter( 12 | (item: any) => item.flag && item.flag.includes('m3u8') 13 | ); 14 | if (videoList.length) { 15 | listStr = videoList[0].text; 16 | } 17 | } else { 18 | listStr = resource.dl.dd.text; 19 | } 20 | } 21 | return { 22 | id: resource.id, 23 | type: resource.type, 24 | picture: resource.pic, 25 | lang: resource.lang, 26 | name: resource.name, 27 | director: resource.director, 28 | describe: resource.des, 29 | area: resource.area, 30 | actor: resource.actor, 31 | class: '', 32 | doubanId: '', 33 | doubanScore: '', 34 | origin: '', 35 | remark: resource.note, 36 | tag: '', 37 | year: resource.year, 38 | updateTime: resource.last, 39 | playList: filterPlayList(listStr) 40 | }; 41 | } 42 | 43 | function filterPlayList(listStr: string) { 44 | const list = new Map(); 45 | const splitLists = listStr.split('$$$').filter(val => val.includes('.m3u8')); 46 | if (splitLists.length) { 47 | splitLists[0].split('#').forEach(item => { 48 | const [key, val] = item.split('$'); 49 | key && val && list.set(key, val); 50 | }); 51 | } 52 | return list; 53 | } 54 | 55 | export function cleanResourceData(dataType: string, data: IplayResource): IplayResource { 56 | const optData: IplayResource = { 57 | id: data.id, 58 | type: data.type, 59 | picture: data.picture, 60 | lang: data.lang, 61 | name: data.name, 62 | director: data.director, 63 | describe: data.describe, 64 | area: data.area, 65 | actor: data.actor, 66 | class: data.class, 67 | doubanId: data.doubanId, 68 | doubanScore: data.doubanScore, 69 | origin: data.origin, 70 | remark: data.remark, 71 | tag: data.tag, 72 | year: data.year, 73 | updateTime: data.updateTime, 74 | playList: data.playList 75 | }; 76 | if (dataType === TABLES.TABLE_HISTORY) { 77 | optData.historyOption = data.historyOption; 78 | } else if (dataType === TABLES.TABLE_COLLECT) { 79 | optData.collectOption = data.collectOption; 80 | } 81 | return optData; 82 | } 83 | -------------------------------------------------------------------------------- /src/renderer/utils/openBrowser.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const path = require('path'); 3 | const childProcess = require('child_process'); 4 | const fs = require('fs'); 5 | const isWsl = require('is-wsl'); 6 | 7 | const pAccess = promisify(fs.access); 8 | const pExecFile = promisify(childProcess.execFile); 9 | 10 | // Path to included `xdg-open` 11 | const localXdgOpenPath = path.join(__dirname, 'xdg-open'); 12 | 13 | // Convert a path from WSL format to Windows format: 14 | // `/mnt/c/Program Files/Example/MyApp.exe` → `C:\Program Files\Example\MyApp.exe` 15 | const wslToWindowsPath = async path1 => { 16 | const { stdout } = await pExecFile('wslpath', ['-w', path1]); 17 | return stdout.trim(); 18 | }; 19 | 20 | module.exports = async (target, options) => { 21 | if (typeof target !== 'string') { 22 | throw new TypeError('Expected a `target`'); 23 | } 24 | 25 | options = { 26 | wait: false, 27 | background: false, 28 | ...options 29 | }; 30 | 31 | let command; 32 | let appArguments = []; 33 | const cliArguments = []; 34 | const childProcessOptions = {}; 35 | 36 | if (Array.isArray(options.app)) { 37 | appArguments = options.app.slice(1); 38 | options.app = options.app[0]; 39 | } 40 | 41 | if (process.platform === 'darwin') { 42 | command = 'open'; 43 | 44 | if (options.wait) { 45 | cliArguments.push('--wait-apps'); 46 | } 47 | 48 | if (options.background) { 49 | cliArguments.push('--background'); 50 | } 51 | 52 | if (options.app) { 53 | cliArguments.push('-a', options.app); 54 | } 55 | } else if (process.platform === 'win32' || isWsl) { 56 | command = `cmd${isWsl ? '.exe' : ''}`; 57 | cliArguments.push('/c', 'start', '""', '/b'); 58 | target = target.replace(/&/g, '^&'); 59 | 60 | if (options.wait) { 61 | cliArguments.push('/wait'); 62 | } 63 | 64 | if (options.app) { 65 | if (isWsl && options.app.startsWith('/mnt/')) { 66 | const windowsPath = await wslToWindowsPath(options.app); 67 | options.app = windowsPath; 68 | } 69 | 70 | cliArguments.push(options.app); 71 | } 72 | 73 | if (appArguments.length > 0) { 74 | cliArguments.push(...appArguments); 75 | } 76 | } else { 77 | if (options.app) { 78 | command = options.app; 79 | } else { 80 | // When bundled by Webpack, there's no actual package file path and no local `xdg-open`. 81 | const isBundled = !__dirname || __dirname === '/'; 82 | 83 | // Check if local `xdg-open` exists and is executable. 84 | let exeLocalXdgOpen = false; 85 | try { 86 | await pAccess(localXdgOpenPath, fs.constants.X_OK); 87 | exeLocalXdgOpen = true; 88 | } catch (error) { 89 | // 90 | } 91 | 92 | const useSystemXdgOpen = 93 | process.versions.electron || 94 | process.platform === 'android' || 95 | isBundled || 96 | !exeLocalXdgOpen; 97 | command = useSystemXdgOpen ? 'xdg-open' : localXdgOpenPath; 98 | } 99 | 100 | if (appArguments.length > 0) { 101 | cliArguments.push(...appArguments); 102 | } 103 | 104 | if (!options.wait) { 105 | // `xdg-open` will block the process unless stdio is ignored 106 | // and it's detached from the parent even if it's unref'd. 107 | childProcessOptions.stdio = 'ignore'; 108 | childProcessOptions.detached = true; 109 | } 110 | } 111 | 112 | cliArguments.push(target); 113 | 114 | if (process.platform === 'darwin' && appArguments.length > 0) { 115 | cliArguments.push('--args', ...appArguments); 116 | } 117 | 118 | const subprocess = childProcess.spawn(command, cliArguments, childProcessOptions); 119 | 120 | if (options.wait) { 121 | return new Promise((resolve, reject) => { 122 | subprocess.once('error', reject); 123 | 124 | subprocess.once('close', exitCode => { 125 | if (exitCode > 0) { 126 | reject(new Error(`Exited with code ${exitCode}`)); 127 | return; 128 | } 129 | 130 | resolve(subprocess); 131 | }); 132 | }); 133 | } 134 | 135 | subprocess.unref(); 136 | 137 | return subprocess; 138 | }; 139 | -------------------------------------------------------------------------------- /src/renderer/utils/request/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { message } from 'antd'; 3 | 4 | const instance = axios.create({ 5 | baseURL: '/', 6 | timeout: 10000 7 | }); 8 | 9 | instance.interceptors.request.use( 10 | config => { 11 | return config; 12 | }, 13 | err => { 14 | message.error(err); 15 | return Promise.reject(err); 16 | } 17 | ); 18 | 19 | // 添加响应拦截器 20 | instance.interceptors.response.use( 21 | ({ data }) => { 22 | return data; 23 | }, 24 | err => { 25 | message.error(err.message); 26 | return null; 27 | } 28 | ); 29 | 30 | export default instance; 31 | -------------------------------------------------------------------------------- /src/renderer/utils/request/modules/queryIptv.ts: -------------------------------------------------------------------------------- 1 | import Req from '@/utils/request'; 2 | import { IPTV_ORIGIN_URL } from '@/utils/constants'; 3 | 4 | export function queryIptvResource(): any { 5 | return Req({ 6 | method: 'get', 7 | url: IPTV_ORIGIN_URL 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/utils/request/modules/queryResources.ts: -------------------------------------------------------------------------------- 1 | import store from '@/utils/store'; 2 | import Req from '../index'; 3 | import xmlParser from '@/utils/xmlParser'; 4 | import { filterResources } from '@/utils/filterResources'; 5 | import { message } from 'antd'; 6 | import { defaultIndexMapper } from '@/utils/constants'; 7 | 8 | // ac:模式(videolist或detail详细模式),为空=列表标准模式 9 | // ids: 影片id,多个使用,隔开 10 | // t: 类型 11 | // h:最近多少小时内 12 | // pg: 页数 13 | // wd:搜索like 14 | // at:输出格式,可选xml 15 | export function queryResources( 16 | curPage: number, 17 | type?: number, 18 | keyWord?: string, 19 | lastUpdate?: number 20 | ): any { 21 | return new Promise(resolve => { 22 | Req({ 23 | method: 'get', 24 | url: store.getState('SITE_ADDRESS').api, 25 | params: { 26 | ac: 'videolist', 27 | pg: curPage, 28 | t: type, 29 | wd: keyWord, 30 | h: lastUpdate 31 | } 32 | }).then(xmlData => { 33 | if (!xmlData) { 34 | resolve(xmlData); 35 | return; 36 | } 37 | try { 38 | const result: IplayResource[] = []; 39 | const parseJson = xmlParser((xmlData as unknown) as string); 40 | const jsonData = parseJson.rss ? parseJson.rss : parseJson; 41 | if (jsonData.list && jsonData.list.video) { 42 | const videoList = 43 | jsonData.list.video instanceof Array 44 | ? jsonData.list.video 45 | : [jsonData.list.video]; 46 | result.push(...filterResources(videoList)); 47 | } 48 | resolve({ 49 | limit: jsonData.list.pagesize, 50 | list: result, 51 | page: jsonData.list.page, 52 | pagecount: jsonData.list.pagecount, 53 | total: jsonData.list.recordcount 54 | }); 55 | } catch (e) { 56 | message.error(e); 57 | resolve(null); 58 | } 59 | }); 60 | }); 61 | } 62 | 63 | export function searchResources(curPage: number, keyWord: string) { 64 | return new Promise(resolve => { 65 | const filterIds: number[] = []; 66 | for (const key in defaultIndexMapper) { 67 | if (key.includes(keyWord)) { 68 | filterIds.push(defaultIndexMapper[key]); 69 | } 70 | } 71 | if (filterIds.length === 0) { 72 | resolve(null); 73 | } else { 74 | Req({ 75 | method: 'get', 76 | url: store.getState('SITE_ADDRESS').api, 77 | params: { 78 | ac: 'videolist', 79 | pg: curPage, 80 | ids: filterIds.join(',') 81 | } 82 | }).then(xmlData => { 83 | if (!xmlData) { 84 | resolve(xmlData); 85 | return; 86 | } 87 | try { 88 | const result: IplayResource[] = []; 89 | const parseJson = xmlParser((xmlData as unknown) as string); 90 | const jsonData = parseJson.rss ? parseJson.rss : parseJson; 91 | if (jsonData.list && jsonData.list.video) { 92 | const videoList = 93 | jsonData.list.video instanceof Array 94 | ? jsonData.list.video 95 | : [jsonData.list.video]; 96 | result.push(...filterResources(videoList)); 97 | } 98 | resolve({ 99 | limit: jsonData.list.pagesize, 100 | list: result, 101 | page: jsonData.list.page, 102 | pagecount: jsonData.list.pagecount, 103 | total: jsonData.list.recordcount 104 | }); 105 | } catch (e) { 106 | message.error(e); 107 | resolve(null); 108 | } 109 | }); 110 | } 111 | }); 112 | } 113 | 114 | export function queryTypes() { 115 | return new Promise(resolve => { 116 | Req({ 117 | method: 'get', 118 | url: store.getState('SITE_ADDRESS').api 119 | }).then(res => { 120 | if (!res) { 121 | resolve(res); 122 | return; 123 | } 124 | try { 125 | const parseJson = xmlParser((res as unknown) as string); 126 | const jsonData = parseJson.rss ? parseJson.rss : parseJson; 127 | resolve(jsonData.class.ty || []); 128 | } catch (e) { 129 | message.error(e); 130 | resolve([]); 131 | } 132 | }); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /src/renderer/utils/store.ts: -------------------------------------------------------------------------------- 1 | import { getTheme } from '@/utils/db/storage'; 2 | 3 | class Store { 4 | private state: Record; 5 | 6 | private subscribers: Record; 7 | 8 | constructor() { 9 | this.state = { 10 | SITE_ADDRESS: undefined, 11 | GLOBAL_LOADING: false, // 全局loading 12 | SEARCH_KEYWORDS: '', // 搜索关键字 13 | CURRENT_PATH: '', // 当前页面路径 14 | GLOBAL_SEARCH_ENABLE: true, // 全局搜索按钮状态 15 | TOMATOX_THEME: getTheme() 16 | }; 17 | this.subscribers = {}; 18 | } 19 | 20 | public setState(key: string, value: any) { 21 | this.state[key] = value; 22 | this.subscribers[key] && 23 | this.subscribers[key].forEach(cb => { 24 | cb(value); 25 | }); 26 | } 27 | 28 | public getState(key: string) { 29 | return this.state[key]; 30 | } 31 | 32 | public subscribe(key: string, cb: Function) { 33 | this.subscribers[key] = this.subscribers[key] || []; 34 | this.subscribers[key].push(cb); 35 | return () => { 36 | this.unSubscribe(key, cb); 37 | }; 38 | } 39 | 40 | public unSubscribe(key: string, cb: Function) { 41 | this.subscribers[key] && 42 | this.subscribers[key].indexOf(cb) >= 0 && 43 | this.subscribers[key].splice(this.subscribers[key].indexOf(cb), 1); 44 | } 45 | } 46 | 47 | export default new Store(); 48 | -------------------------------------------------------------------------------- /src/renderer/utils/xmlParser.ts: -------------------------------------------------------------------------------- 1 | import XMLParser from 'fast-xml-parser'; 2 | 3 | const xmlConfig = { 4 | // XML 转 JSON 配置 5 | trimValues: true, 6 | textNodeName: 'text', 7 | ignoreAttributes: false, 8 | attributeNamePrefix: '', 9 | parseAttributeValue: true 10 | }; 11 | 12 | export default function(xmlData: string) { 13 | return XMLParser.parse(xmlData, xmlConfig); 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/views/about/about.scss: -------------------------------------------------------------------------------- 1 | .logo-wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | height: calc(100vh - 150px); 7 | span:nth-of-type(1) { 8 | line-height: 30px; 9 | margin-top: 20px; 10 | font-size: 20px; 11 | font-weight: bold; 12 | .check-update { 13 | cursor: pointer; 14 | transition: all 0.2s linear; 15 | &:hover { 16 | color: #ff5c49; 17 | } 18 | } 19 | span { 20 | line-height: 10px; 21 | font-size: 13px; 22 | margin: 0 5px 0 8px; 23 | font-weight: normal; 24 | } 25 | } 26 | span:nth-of-type(2) { 27 | line-height: 30px; 28 | } 29 | .gh-icon { 30 | font-size: 25px; 31 | margin-left: 15px; 32 | cursor: pointer; 33 | transition: all 0.2s linear; 34 | &:hover { 35 | color: #ff5c49; 36 | } 37 | } 38 | } 39 | .prod-statement-wrapper { 40 | position: absolute; 41 | width: 100%; 42 | height: auto; 43 | bottom: 0; 44 | left: 0; 45 | display: flex; 46 | padding-bottom: 20px; 47 | justify-content: center; 48 | .prod-statement { 49 | white-space: pre-wrap; 50 | clear: both; 51 | font-size: 12px; 52 | color: #949494; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/renderer/views/about/about.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cssM from './about.scss'; 3 | import logo from '@/images/svg/icon.svg'; 4 | import { 5 | GithubOutlined, 6 | SyncOutlined, 7 | CheckCircleOutlined, 8 | ArrowUpOutlined, 9 | CloseCircleOutlined 10 | } from '@ant-design/icons'; 11 | import { ipcRenderer } from 'electron'; 12 | import { PROD_STATEMENT } from '@/utils/constants'; 13 | 14 | const openBrowser = require('@/utils/openBrowser'); 15 | const { version } = require('@/../../package.json'); 16 | const path = require('path'); 17 | 18 | export default class About extends React.Component { 19 | constructor(props: any) { 20 | super(props); 21 | this.state = { 22 | updateStatus: 0, // 0: 未检查, 1:检查中,2:无新版本,3:有新版本,4:下载完成,等待安装,5:更新失败,6:正在下载,7:请求下载中 23 | newVersion: '', 24 | percent: 0, 25 | bytesPerSecond: '', 26 | transferred: '', 27 | total: '' 28 | // note: '' 29 | }; 30 | } 31 | 32 | componentWillMount(): void { 33 | ipcRenderer 34 | .on('checking-for-update', () => { 35 | this.setState({ 36 | updateStatus: 1 37 | }); 38 | }) 39 | .on('update-available', (event: any, info: any) => { 40 | this.setState({ 41 | updateStatus: 3, 42 | newVersion: info.version 43 | // note: info.releaseNotes 44 | }); 45 | }) 46 | .on('update-not-available', () => { 47 | this.setState({ 48 | updateStatus: 2 49 | }); 50 | }) 51 | .on('update-error', () => { 52 | this.setState({ 53 | updateStatus: 5 54 | }); 55 | }) 56 | .on('update-downloaded', () => { 57 | this.setState({ 58 | updateStatus: 4 59 | }); 60 | }) 61 | .on('download-progress', (event: any, procInfo: any) => { 62 | this.setState({ 63 | updateStatus: 6, 64 | percent: Math.floor(procInfo.percent || 0), 65 | bytesPerSecond: this.convertBytes(procInfo.bytesPerSecond || 0), 66 | transferred: this.convertBytes(procInfo.transferred || 0), 67 | total: this.convertBytes(procInfo.total || 0) 68 | }); 69 | }); 70 | } 71 | 72 | private convertBytes(bytes: number) { 73 | if (bytes > 1024 * 1024) { 74 | return `${Math.floor(bytes / 1024 / 1024)}MB`; 75 | } 76 | if (bytes > 1024) { 77 | return `${Math.floor(bytes / 1024)}KB`; 78 | } 79 | return `${Math.floor(bytes)}B`; 80 | } 81 | 82 | private checkUpdate = () => { 83 | this.setState({ 84 | updateStatus: 1 85 | }); 86 | ipcRenderer.send('checkForUpdate'); 87 | }; 88 | 89 | private downloadNew = () => { 90 | this.setState({ 91 | updateStatus: 7 92 | }); 93 | ipcRenderer.send('downloadUpdate'); 94 | }; 95 | 96 | private installNew = () => { 97 | ipcRenderer.send('quitAndInstall'); 98 | }; 99 | 100 | render(): React.ReactNode { 101 | return ( 102 |
103 |
104 | 105 | 106 | TOMATOX {version} 107 | 108 | {this.state.updateStatus === 0 && ( 109 | 110 | 111 | 检查更新 112 | 113 | )} 114 | {this.state.updateStatus === 1 && ( 115 | 116 | 117 | 正在检查更新 118 | 119 | )} 120 | {this.state.updateStatus === 2 && ( 121 | 122 | 123 | 已是最新版本 124 | 125 | )} 126 | {this.state.updateStatus === 3 && ( 127 | 128 | 129 | 发现新版本({this.state.newVersion || ''}),点击更新 130 | 131 | )} 132 | {this.state.updateStatus === 4 && ( 133 | 134 | 135 | 下载完毕,点击安装 136 | 137 | )} 138 | {this.state.updateStatus === 5 && ( 139 | 140 | 141 | (检查)更新失败,点击重试 142 | 143 | )} 144 | {this.state.updateStatus === 6 && ( 145 | 146 | 147 | 正在下载新版本{' '} 148 | {`${this.state.transferred}/${this.state.total} ${this.state.percent}% ${this.state.bytesPerSecond}/s`} 149 | 150 | )} 151 | {this.state.updateStatus === 7 && ( 152 | 153 | 154 | 正在请求更新 155 | 156 | )} 157 | 158 | 159 | 160 | Author: Freeless 161 | { 164 | openBrowser('https://github.com/yanjiaxuan/TOMATOX'); 165 | }} 166 | /> 167 | 168 |
169 |
170 |
{PROD_STATEMENT}
171 |
172 |
173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/renderer/views/classify/classify.scss: -------------------------------------------------------------------------------- 1 | .full-wrapper { 2 | width: 100%; 3 | height: calc(100vh - 50px); 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | .scroll-wrapper { 9 | width: 100%; 10 | overflow-y: auto; 11 | overflow-x: hidden; 12 | transform: translateZ(0); 13 | } 14 | 15 | .type-wrapper { 16 | width: 100%; 17 | padding: 5px 20px 0 20px; 18 | padding-bottom: 10px; 19 | } 20 | 21 | .type-item { 22 | display: inline-block; 23 | position: relative; 24 | cursor: pointer; 25 | padding: 10px 10px 0 10px; 26 | font-size: 15px; 27 | &-active:after { 28 | transition: all 0.5s linear; 29 | content: ''; 30 | position: absolute; 31 | top: 35px; 32 | left: 50%; 33 | transform: translate(-50%); 34 | width: 30px; 35 | height: 3px; 36 | background-color: #ff5c49; 37 | border-radius: 2px; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/views/classify/classify.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import InfiniteScroll from 'react-infinite-scroller'; 3 | import TomatoxWaterfall from '@/components/tomatox-waterfall/tomatox-waterfall'; 4 | import { Spin } from 'antd'; 5 | import CustomSpin from '@/components/custom-spin/custom-spin'; 6 | import { queryResources, queryTypes } from '@/utils/request/modules/queryResources'; 7 | import store from '@/utils/store'; 8 | import { filterResources } from '@/utils/filterResources'; 9 | import cssM from './classify.scss'; 10 | 11 | export default class Classify extends React.Component { 12 | private page = 0; 13 | private pageCount = 10; 14 | 15 | constructor(props: any) { 16 | super(props); 17 | this.state = { 18 | types: {}, 19 | selectedType: '', 20 | cardsData: [], 21 | recommendLoading: false 22 | }; 23 | } 24 | 25 | async componentWillMount() { 26 | this.initResource(); 27 | store.subscribe('SITE_ADDRESS', () => { 28 | this.page = 0; 29 | this.pageCount = 10; 30 | this.setState( 31 | { 32 | types: {}, 33 | selectedType: '', 34 | cardsData: [], 35 | recommendLoading: false 36 | }, 37 | this.initResource 38 | ); 39 | }); 40 | } 41 | 42 | initResource() { 43 | store.setState('GLOBAL_LOADING', true); 44 | queryTypes().then( 45 | (res: any) => { 46 | if (!res) { 47 | store.setState('GLOBAL_LOADING', false); 48 | return; 49 | } 50 | const types: Record = {}; 51 | res.forEach((item: any) => { 52 | types[item.text] = item.id; 53 | }); 54 | this.setState({ 55 | types, 56 | selectedType: Object.keys(types)[0] 57 | }); 58 | this.getRecommendLst(); 59 | }, 60 | reason => { 61 | if (store.getState('GLOBAL_LOADING')) { 62 | store.setState('GLOBAL_LOADING', false); 63 | } 64 | } 65 | ); 66 | } 67 | 68 | private getRecommendLst() { 69 | Promise.all([ 70 | queryResources(++this.page, this.state.types[this.state.selectedType]), 71 | queryResources(++this.page, this.state.types[this.state.selectedType]), 72 | queryResources(++this.page, this.state.types[this.state.selectedType]) 73 | ]).then( 74 | reses => { 75 | const allList: IplayResource[] = []; 76 | reses.forEach(res => { 77 | if (!res) { 78 | this.pageCount = 0; 79 | return; 80 | } 81 | const { list, pagecount } = res; 82 | this.pageCount = pagecount; 83 | allList.push(...list); 84 | }); 85 | if (store.getState('GLOBAL_LOADING')) { 86 | store.setState('GLOBAL_LOADING', false); 87 | } 88 | this.setState({ 89 | recommendLoading: this.page < this.pageCount, 90 | cardsData: [...this.state.cardsData, ...allList] 91 | }); 92 | }, 93 | reason => { 94 | if (store.getState('GLOBAL_LOADING')) { 95 | store.setState('GLOBAL_LOADING', false); 96 | } 97 | } 98 | ); 99 | } 100 | 101 | private changeType(key: string, item: number) { 102 | store.setState('GLOBAL_LOADING', true); 103 | this.setState( 104 | this.setState({ 105 | selectedType: key, 106 | cardsData: [], 107 | recommendLoading: false 108 | }), 109 | () => { 110 | this.page = 0; 111 | this.getRecommendLst(); 112 | } 113 | ); 114 | } 115 | 116 | renderClassify() { 117 | const res: ReactElement[] = []; 118 | Object.keys(this.state.types).forEach(item => { 119 | res.push( 120 | this.changeType(item, this.state.types[item])}> 126 | {item} 127 | 128 | ); 129 | }); 130 | return res; 131 | } 132 | 133 | render(): React.ReactNode { 134 | return ( 135 |
136 |
137 | {this.renderClassify()} 138 |
139 |
140 | 146 | 147 | {this.state.recommendLoading && ( 148 |
149 | } 152 | spinning={this.state.recommendLoading} 153 | /> 154 |
155 | )} 156 |
157 |
158 |
159 | ); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/renderer/views/collect/collect.scss: -------------------------------------------------------------------------------- 1 | .scroll-wrapper { 2 | width: 100%; 3 | height: 100%; 4 | overflow-y: auto; 5 | overflow-x: hidden; 6 | transform: translateZ(0); 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/views/collect/collect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TomatoxWaterfall from '@/components/tomatox-waterfall/tomatox-waterfall'; 3 | import Indexed from '@/utils/db/indexed'; 4 | import { TABLES } from '@/utils/constants'; 5 | import cssM from './collect.scss'; 6 | 7 | export default class Collect extends React.Component { 8 | constructor(props: any) { 9 | super(props); 10 | this.state = { 11 | resources: [] 12 | }; 13 | } 14 | 15 | async componentWillMount() { 16 | const res = (await Indexed.instance!.queryAll(TABLES.TABLE_COLLECT)) as IplayResource[]; 17 | res.sort((a, b) => b.collectOption!.collectDate! - a.collectOption!.collectDate!); 18 | this.setState({ 19 | resources: res 20 | }); 21 | } 22 | 23 | render() { 24 | return ( 25 |
26 | 27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/views/developing/developing.scss: -------------------------------------------------------------------------------- 1 | .developing { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-content: center; 8 | text-align: center; 9 | color: #fff; 10 | span { 11 | margin-top: 10px; 12 | font-size: 15px; 13 | } 14 | img { 15 | width: 60px; 16 | } 17 | } -------------------------------------------------------------------------------- /src/renderer/views/developing/developing.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TOMATOX_ICON from '@/images/svg/icon.svg'; 3 | import cssM from './developing.scss'; 4 | 5 | export default function developing(props: any) { 6 | return ( 7 |
8 |
9 | 10 |
11 | 功能正在马不停蹄的开发中 12 |
13 | ) 14 | } -------------------------------------------------------------------------------- /src/renderer/views/history/history.scss: -------------------------------------------------------------------------------- 1 | .year-month-style { 2 | font-size: 25px; 3 | font-weight: bold; 4 | padding-left: 30px; 5 | margin-top: 20px; 6 | } 7 | 8 | .day-style { 9 | font-size: 18px; 10 | font-weight: bold; 11 | padding-left: 30px; 12 | } 13 | 14 | .scroll-wrapper { 15 | height: 100%; 16 | width: 100%; 17 | overflow-y: auto; 18 | overflow-x: hidden; 19 | transform: translateZ(0); 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/views/history/history.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState } from 'react'; 2 | import Indexed from '@/utils/db/indexed'; 3 | import { TABLES } from '@/utils/constants'; 4 | import TomatoxWaterfall from '@/components/tomatox-waterfall/tomatox-waterfall'; 5 | import cssM from './history.scss'; 6 | 7 | function compareYMStr(a: string, b: string): number { 8 | return +a.replace('年', '').replace('月', '') - +b.replace('年', '').replace('月', ''); 9 | } 10 | 11 | function compareDStr(a: string, b: string): number { 12 | return parseInt(b, 10) - parseInt(a, 10); 13 | } 14 | 15 | export default class History extends React.Component { 16 | constructor(props: any) { 17 | super(props); 18 | this.state = { 19 | resourceList: new Map>() 20 | }; 21 | } 22 | 23 | async componentWillMount() { 24 | const res = await Indexed.instance!.queryAll(TABLES.TABLE_HISTORY); 25 | const resources = res as IplayResource[]; 26 | const resMap = new Map>(); 27 | // step1: convert list to map 28 | resources.forEach(resource => { 29 | const date = new Date(resource.historyOption!.lastPlayDate!); 30 | const yearMonth = `${date.getFullYear()}年${date.getMonth() + 1}月`; 31 | const day = `${date.getDate()}日`; 32 | if (!resMap.get(yearMonth)) { 33 | resMap.set(yearMonth, new Map()); 34 | } 35 | if (!resMap.get(yearMonth)!.get(day)) { 36 | resMap.get(yearMonth)!.set(day, new Array()); 37 | } 38 | resMap 39 | .get(yearMonth)! 40 | .get(day)! 41 | .push(resource); 42 | }); 43 | // step2: sort 44 | const ymSortedArr = Array.from(resMap.keys()).sort(compareYMStr); 45 | const sortedMap = new Map>(); 46 | ymSortedArr.forEach((ym: string) => { 47 | const dayMap = resMap.get(ym)!; 48 | const daySortedMap = new Map(); 49 | const daySortedArr = Array.from(dayMap.keys()).sort(compareDStr); 50 | daySortedArr.forEach(day => { 51 | daySortedMap.set( 52 | day, 53 | dayMap 54 | .get(day)! 55 | .sort( 56 | (a, b) => 57 | b.historyOption!.lastPlayDate! - a.historyOption!.lastPlayDate! 58 | ) 59 | ); 60 | }); 61 | sortedMap.set(ym, daySortedMap); 62 | }); 63 | this.setState({ 64 | resourceList: sortedMap 65 | }); 66 | } 67 | 68 | renderD(dData: Map, ym: string) { 69 | const res: ReactElement[] = []; 70 | dData.forEach((value, key) => { 71 | res.push( 72 |
73 |
{ym}
74 |
{key}
75 | 76 |
77 | ); 78 | }); 79 | return res; 80 | } 81 | 82 | renderYM(ymData: Map>) { 83 | const res: ReactElement[] = []; 84 | ymData.forEach((value, key) => { 85 | res.unshift(
{this.renderD(value, key)}
); 86 | }); 87 | return res; 88 | } 89 | 90 | render(): React.ReactNode { 91 | return
{this.renderYM(this.state.resourceList)}
; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/renderer/views/iptv/iptv-player/iptv-player.scss: -------------------------------------------------------------------------------- 1 | .full-screen { 2 | position: fixed; 3 | padding-top: 30px; 4 | width: 100vw; 5 | height: 100vh; 6 | left: 0; 7 | top: 0; 8 | overflow: hidden; 9 | } 10 | 11 | .play-full-header { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | width: 100%; 16 | height: 30px; 17 | z-index: 1; 18 | font-size: 14px; 19 | line-height: 30px; 20 | display: flex; 21 | flex-wrap: nowrap; 22 | justify-content: space-between; 23 | -webkit-app-region: drag; 24 | & > span { 25 | &:nth-of-type(1) { 26 | user-select: none; 27 | cursor: pointer; 28 | display: inline-block; 29 | height: 100%; 30 | padding: 0 14px; 31 | -webkit-app-region: no-drag; 32 | } 33 | &:nth-of-type(3) { 34 | -webkit-app-region: no-drag; 35 | & > span { 36 | padding: 0 5px; 37 | cursor: pointer; 38 | transition: all 0.5s ease-in-out; 39 | &:nth-of-type(3) { 40 | margin-right: 5px; 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/views/iptv/iptv-player/iptv-player.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cssM from './iptv-player.scss'; 3 | import { Control } from 'react-keeper'; 4 | import { 5 | LeftOutlined, 6 | MinusOutlined, 7 | BlockOutlined, 8 | CloseOutlined, 9 | HeartOutlined, 10 | HeartFilled 11 | } from '@ant-design/icons'; 12 | import { getPlayConfig, setPlayConfig } from '@/utils/db/storage'; 13 | import shortcutManager from 'electron-localshortcut'; 14 | import XGPlayer from 'xgplayer'; 15 | 16 | const HlsPlayer = require('xgplayer-hls.js'); 17 | const FlvPlayer = require('xgplayer-flv.js'); 18 | const { ipcRenderer, remote } = require('electron'); 19 | 20 | export default class IptvPlayer extends React.Component { 21 | private xgPlayer: XGPlayer | undefined; 22 | private mainEventHandler: Record void> = { 23 | Up: () => { 24 | this.xgPlayer!.volume = Math.min(this.xgPlayer!.volume + 0.1, 1); 25 | }, 26 | Down: () => { 27 | this.xgPlayer!.volume = Math.max(this.xgPlayer!.volume - 0.1, 0); 28 | }, 29 | Right: () => { 30 | this.xgPlayer!.currentTime = Math.min( 31 | this.xgPlayer!.currentTime + 10, 32 | this.xgPlayer!.duration 33 | ); 34 | }, 35 | Left: () => { 36 | this.xgPlayer!.currentTime = Math.max(this.xgPlayer!.currentTime - 10, 0); 37 | }, 38 | Space: () => { 39 | this.xgPlayer!.paused ? this.xgPlayer!.play() : this.xgPlayer!.pause(); 40 | } 41 | }; 42 | 43 | constructor(props: any) { 44 | super(props); 45 | this.state = { 46 | resource: Control.state 47 | }; 48 | } 49 | 50 | private updateVolumeConf = () => { 51 | setPlayConfig({ voice: this.xgPlayer!.volume }); 52 | }; 53 | 54 | private updateSpeedConf = () => { 55 | setPlayConfig({ speed: this.xgPlayer!.playbackRate }); 56 | }; 57 | 58 | componentDidMount(): void { 59 | const PlayerClass = (this.state.resource.src.includes('.m3u8') 60 | ? HlsPlayer 61 | : FlvPlayer) as any; 62 | this.xgPlayer = new PlayerClass({ 63 | el: this.refs.iptvPlayer as any, 64 | url: this.state.resource.src, 65 | id: 'tomatox-iptv', 66 | lang: 'zh-cn', 67 | width: '100%', 68 | height: '100%', 69 | autoplay: false, 70 | videoInit: true, 71 | screenShot: true, 72 | keyShortcut: 'off', 73 | crossOrigin: true, 74 | cssFullscreen: true, 75 | volume: getPlayConfig().voice, 76 | defaultPlaybackRate: getPlayConfig().speed, 77 | playPrev: true, 78 | playNextOne: true, 79 | videoStop: true, 80 | showList: true, 81 | showHistory: true, 82 | quitMiniMode: true, 83 | videoTitle: true, 84 | airplay: true, 85 | closeVideoTouch: true, 86 | ignores: ['replay', 'error'], // 为了切换播放器类型时避免显示错误刷新,暂时忽略错误 87 | preloadTime: 300 88 | }); 89 | this.xgPlayer?.play(); 90 | this.xgPlayer?.on('volumechange', this.updateVolumeConf); 91 | this.xgPlayer?.on('playbackrateChange', this.updateSpeedConf); 92 | for (const key in this.mainEventHandler) { 93 | shortcutManager.register(remote.getCurrentWindow(), key, this.mainEventHandler[key]); 94 | } 95 | } 96 | 97 | componentWillUnmount(): void { 98 | shortcutManager.unregister(remote.getCurrentWindow(), Object.keys(this.mainEventHandler)); 99 | this.xgPlayer!.src = ''; 100 | this.xgPlayer?.off('volumechange', this.updateVolumeConf); 101 | this.xgPlayer?.off('playbackrateChange', this.updateSpeedConf); 102 | this.xgPlayer?.destroy(); 103 | } 104 | 105 | render(): React.ReactNode { 106 | return ( 107 |
108 |
109 | { 111 | Control.go(-1); 112 | }}> 113 | 返回 114 | 115 | 116 | {this.state.resource.sourceName} 117 | 118 | 119 | { 121 | ipcRenderer.send('WINDOW_MIN'); 122 | }} 123 | /> 124 | { 126 | ipcRenderer.send('WINDOW_MAX'); 127 | }} 128 | /> 129 | { 131 | ipcRenderer.send('WINDOW_CLOSE'); 132 | }} 133 | /> 134 | 135 |
136 |
137 |
138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/renderer/views/iptv/iptv.scss: -------------------------------------------------------------------------------- 1 | .card-wrapper { 2 | padding: 10px 20px 20px 20px; 3 | } 4 | 5 | .iptv-card { 6 | display: inline-block; 7 | margin: 5px 10px; 8 | padding-left: 20px; 9 | width: 150px; 10 | height: 30px; 11 | line-height: 25px; 12 | border-radius: 2px; 13 | cursor: pointer; 14 | text-overflow: ellipsis; 15 | white-space: nowrap; 16 | word-break: break-all; 17 | overflow: hidden; 18 | position: relative; 19 | transition: all 0.3s ease-in-out; 20 | &::before { 21 | transition: all 0.3s ease-in-out; 22 | content: ''; 23 | position: absolute; 24 | top: 50%; 25 | left: 0; 26 | transform: translateY(-60%); 27 | width: 3px; 28 | height: 15px; 29 | background-color: #ff5c49; 30 | } 31 | &:hover { 32 | color: #ff5c49; 33 | } 34 | } 35 | 36 | .search-wrapper { 37 | position: absolute; 38 | top: 0; 39 | width: 100%; 40 | height: 55px; 41 | padding: 15px 50px 25px 25px; 42 | z-index: 1; 43 | background-color: unset !important; 44 | input { 45 | float: unset !important; 46 | } 47 | } 48 | 49 | .scroll-wrapper { 50 | width: 100%; 51 | height: calc(100% - 55px); 52 | overflow-x: hidden; 53 | margin-top: 50px; 54 | overflow-y: auto; 55 | transform: translateZ(0); 56 | } 57 | -------------------------------------------------------------------------------- /src/renderer/views/iptv/iptv.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import cssM from './iptv.scss'; 3 | import { queryIptvResource } from '@/utils/request/modules/queryIptv'; 4 | import { Link } from 'react-keeper'; 5 | import { Input, Space } from 'antd'; 6 | import { SearchOutlined } from '@ant-design/icons'; 7 | 8 | const { Search } = Input; 9 | 10 | export default class Iptv extends React.Component { 11 | private allResources: Array<{ sourceName: string; src: string }> = []; 12 | 13 | constructor(props: any) { 14 | super(props); 15 | this.state = { 16 | sources: [] 17 | }; 18 | } 19 | async componentWillMount() { 20 | const res = 21 | ((await queryIptvResource()) as Array<{ sourceName: string; src: string }>) || []; 22 | this.allResources.push(...res); 23 | this.setState({ 24 | sources: this.allResources 25 | }); 26 | } 27 | 28 | private filterResources = (kw: string) => { 29 | this.setState({ 30 | sources: this.allResources.filter(item => 31 | item.sourceName.toLowerCase().includes(kw.toLowerCase()) 32 | ) 33 | }); 34 | }; 35 | 36 | private renderSources = () => { 37 | const res: ReactElement[] = []; 38 | this.state.sources.forEach((item: { sourceName: string; src: string }) => { 39 | res.push( 40 | 41 | 42 | {item.sourceName} 43 | 44 | 45 | ); 46 | }); 47 | return res; 48 | }; 49 | 50 | render(): React.ReactNode { 51 | return ( 52 |
53 |
54 | 59 | 搜索 60 | 61 | } 62 | /> 63 |
64 |
65 |
{this.renderSources()}
66 |
67 |
68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/renderer/views/player/palyer.scss: -------------------------------------------------------------------------------- 1 | .play-page-wrapper { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100vw; 6 | height: 100vh; 7 | } 8 | .play-full-header { 9 | position: relative; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 30px; 14 | z-index: 1; 15 | font-size: 14px; 16 | line-height: 30px; 17 | display: flex; 18 | flex-wrap: nowrap; 19 | justify-content: space-between; 20 | -webkit-app-region: drag; 21 | & > span { 22 | &:nth-of-type(1) { 23 | user-select: none; 24 | cursor: pointer; 25 | display: inline-block; 26 | height: 100%; 27 | padding: 0 14px; 28 | -webkit-app-region: no-drag; 29 | } 30 | &:nth-of-type(3) { 31 | -webkit-app-region: no-drag; 32 | & > span { 33 | padding: 0 5px; 34 | cursor: pointer; 35 | transition: all 0.5s ease-in-out; 36 | &:nth-of-type(3) { 37 | margin-right: 5px; 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | .play-full-wrapper { 45 | position: relative; 46 | width: 100%; 47 | height: calc(100vh - 30px); 48 | left: 0; 49 | top: 0; 50 | display: flex; 51 | overflow: hidden; 52 | } 53 | 54 | .player-wrapper { 55 | width: calc(100vw - 350px); 56 | height: 100%; 57 | border: unset !important; 58 | } 59 | .video-info-wrapper { 60 | min-width: 350px; 61 | width: 350px; 62 | max-width: 350px; 63 | padding: 15px 0 15px 15px; 64 | height: 100%; 65 | } 66 | .video-info-title { 67 | font-size: 14px; 68 | } 69 | 70 | .sourceTab { 71 | height: 100%; 72 | } 73 | 74 | .tabContent { 75 | height: 100%; 76 | overflow-y: auto; 77 | } 78 | 79 | .video-frame { 80 | border: 0; 81 | width: 100%; 82 | height: 100%; 83 | } 84 | 85 | .series-tag { 86 | white-space: nowrap; 87 | text-overflow: ellipsis; 88 | overflow: hidden; 89 | 90 | width: 95px; 91 | height: 30px; 92 | text-align: center; 93 | line-height: 30px; 94 | margin: 1px 4px; 95 | display: inline-block; 96 | background-color: rgba(128, 128, 128, 0.4); 97 | color: #fff; 98 | font-size: 13px; 99 | cursor: pointer; 100 | transition: all 0.2s linear; 101 | &:hover { 102 | color: #e05f2d; 103 | } 104 | } 105 | 106 | .series-tag-active { 107 | background-color: #e05f2d; 108 | &:hover { 109 | color: #fff; 110 | } 111 | } 112 | 113 | .detail-header-wrapper { 114 | display: flex; 115 | flex-direction: row; 116 | flex-wrap: nowrap; 117 | padding-right: 15px; 118 | .detail-image { 119 | max-width: 150px; 120 | min-width: 150px; 121 | max-height: 220px; 122 | min-height: 220px; 123 | margin-right: 10px; 124 | object-fit: cover; 125 | } 126 | .detail-text-wrapper { 127 | display: flex; 128 | flex-direction: column; 129 | flex-wrap: nowrap; 130 | justify-content: space-between; 131 | .detail-title { 132 | font-size: 14px; 133 | overflow: hidden; 134 | text-overflow: ellipsis; 135 | -webkit-line-clamp: 2; 136 | -webkit-box-orient: vertical; 137 | max-height: 44px; 138 | width: 140px; 139 | } 140 | .detail-content { 141 | word-wrap: break-word; 142 | width: 140px; 143 | font-size: 12px; 144 | margin: 5px 0; 145 | } 146 | } 147 | } 148 | 149 | .detailNoteWrapper { 150 | padding-right: 15px; 151 | } 152 | 153 | .detail-note-first { 154 | margin: 10px 0 0 0; 155 | padding: 10px 15px 0 15px; 156 | background-color: #d8cfcf29; 157 | } 158 | .detail-note-second { 159 | margin-bottom: 10px; 160 | padding: 10px 15px; 161 | background-color: #d8cfcf29; 162 | } 163 | 164 | .detail-desc-title { 165 | margin-bottom: 5px; 166 | } 167 | 168 | .detail-desc { 169 | font-size: 12px; 170 | line-height: 1.6; 171 | p, 172 | span { 173 | background-color: unset !important; 174 | } 175 | } 176 | 177 | .resource-collect { 178 | cursor: pointer; 179 | margin-left: 15px; 180 | color: #ff5c49 !important; 181 | -webkit-app-region: no-drag; 182 | } 183 | .resource-not-collect { 184 | margin-left: 15px; 185 | cursor: pointer; 186 | -webkit-app-region: no-drag; 187 | } 188 | -------------------------------------------------------------------------------- /src/renderer/views/player/player.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | LeftOutlined, 4 | MinusOutlined, 5 | BlockOutlined, 6 | CloseOutlined, 7 | HeartOutlined, 8 | HeartFilled 9 | } from '@ant-design/icons'; 10 | import { Control } from 'react-keeper'; 11 | import { Tag, Tabs } from 'antd'; 12 | 13 | import XGPlayer from 'xgplayer'; 14 | import shortcutManager from 'electron-localshortcut'; 15 | import Indexed from '@/utils/db/indexed'; 16 | import { TABLES } from '@/utils/constants'; 17 | import cssM from './palyer.scss'; 18 | import { getPlayConfig, setPlayConfig } from '@/utils/db/storage'; 19 | 20 | const HlsPlayer = require('xgplayer-hls.js'); 21 | const { ipcRenderer, remote } = require('electron'); 22 | 23 | export default class Player extends React.Component { 24 | private xgPlayer: XGPlayer | undefined; 25 | private sourceList: Map> = new Map(); 26 | private selectedKey = '播放列表'; 27 | private controlState: IplayResource; 28 | private mainEventHandler: Record void> = { 29 | Up: () => { 30 | this.xgPlayer!.volume = Math.min(this.xgPlayer!.volume + 0.1, 1); 31 | }, 32 | Down: () => { 33 | this.xgPlayer!.volume = Math.max(this.xgPlayer!.volume - 0.1, 0); 34 | }, 35 | Right: () => { 36 | this.xgPlayer!.currentTime = Math.min( 37 | this.xgPlayer!.currentTime + 10, 38 | this.xgPlayer!.duration 39 | ); 40 | }, 41 | Left: () => { 42 | this.xgPlayer!.currentTime = Math.max(this.xgPlayer!.currentTime - 10, 0); 43 | }, 44 | Space: () => { 45 | this.xgPlayer!.paused ? this.xgPlayer!.play() : this.xgPlayer!.pause(); 46 | } 47 | }; 48 | 49 | constructor(props: any) { 50 | super(props); 51 | this.controlState = Control.state; 52 | if (this.controlState) { 53 | this.sourceList.set('播放列表', this.controlState.playList); 54 | this.state = { 55 | curPlaySrc: 56 | this.controlState.historyOption?.lastPlaySrc || 57 | this.controlState.playList.values().next().value, 58 | curPlayDrama: 59 | this.controlState.historyOption?.lastPlayDrama || 60 | this.controlState.playList.keys().next().value, 61 | isCollect: Indexed.collectedRes.has(this.controlState.id) 62 | }; 63 | } 64 | } 65 | 66 | private playNext = () => { 67 | const dramas = Array.from(this.sourceList.get(this.selectedKey)!.keys()); 68 | const curIdx = dramas.indexOf(this.state.curPlayDrama); 69 | if (curIdx >= 0 && curIdx < dramas.length - 1) { 70 | const drama = dramas[curIdx + 1]; 71 | const src = this.sourceList.get(this.selectedKey)!.get(dramas[curIdx + 1])!; 72 | this.setState({ 73 | curPlayDrama: drama, 74 | curPlaySrc: src 75 | }); 76 | this.xgPlayer!.src = src; 77 | } 78 | }; 79 | 80 | doCollect() { 81 | this.setState({ 82 | isCollect: true 83 | }); 84 | Indexed.instance!.doCollect(this.controlState); 85 | } 86 | 87 | cancelCollect() { 88 | this.setState({ 89 | isCollect: false 90 | }); 91 | Indexed.instance!.cancelCollect(this.controlState.id); 92 | } 93 | 94 | componentDidMount(): void { 95 | this.xgPlayer = new HlsPlayer({ 96 | el: this.refs.playWrapperRef as any, 97 | url: this.state.curPlaySrc, 98 | id: 'tomatox', 99 | lang: 'zh-cn', 100 | width: '100%', 101 | height: '100%', 102 | autoplay: false, 103 | videoInit: true, 104 | screenShot: true, 105 | keyShortcut: 'off', 106 | crossOrigin: true, 107 | cssFullscreen: true, 108 | volume: getPlayConfig().voice, 109 | defaultPlaybackRate: getPlayConfig().speed, 110 | playbackRate: [0.5, 1, 1.25, 1.5, 2], 111 | playPrev: true, 112 | playNextOne: true, 113 | videoStop: true, 114 | showList: true, 115 | showHistory: true, 116 | quitMiniMode: true, 117 | videoTitle: true, 118 | airplay: true, 119 | closeVideoTouch: true, 120 | ignores: ['replay', 'error'], // 为了切换播放器类型时避免显示错误刷新,暂时忽略错误 121 | preloadTime: 300 122 | }); 123 | this.xgPlayer!.currentTime = this.controlState.historyOption?.lastPlayTime || 0; 124 | this.xgPlayer?.play(); 125 | this.xgPlayer?.on('ended', this.playNext); 126 | this.xgPlayer?.on('volumechange', this.updateVolumeConf); 127 | this.xgPlayer?.on('playbackrateChange', this.updateSpeedConf); 128 | for (const key in this.mainEventHandler) { 129 | shortcutManager.register(remote.getCurrentWindow(), key, this.mainEventHandler[key]); 130 | } 131 | } 132 | 133 | private updateVolumeConf = () => { 134 | setPlayConfig({ voice: this.xgPlayer!.volume }); 135 | }; 136 | 137 | private updateSpeedConf = () => { 138 | setPlayConfig({ speed: this.xgPlayer!.playbackRate }); 139 | }; 140 | 141 | private static timeConverter(time: number) { 142 | const hours = Math.floor(time / 3600); 143 | const minutes = Math.floor((time % 3600) / 60); 144 | const senconds = Math.floor(time % 60); 145 | const ms = `${minutes < 10 ? '0' : ''}${minutes}:${senconds < 10 ? '0' : ''}${senconds}`; 146 | return hours === 0 ? ms : `${hours < 10 ? '0' : ''}${hours}:${ms}`; 147 | } 148 | 149 | componentWillUnmount(): void { 150 | const newData: IplayResource = { 151 | ...this.controlState, 152 | historyOption: { 153 | lastPlayDrama: this.state.curPlayDrama, 154 | lastPlaySrc: this.state.curPlaySrc, 155 | lastPlayTime: this.xgPlayer?.currentTime || 0, 156 | lastPlayDate: Date.now(), 157 | lastPlayDesc: `观看至 ${this.state.curPlayDrama || ''} ${Player.timeConverter( 158 | this.xgPlayer?.currentTime || 0 159 | )}` 160 | } 161 | }; 162 | Indexed.instance!.insertOrUpdateResource(TABLES.TABLE_HISTORY, newData); 163 | 164 | shortcutManager.unregister(remote.getCurrentWindow(), Object.keys(this.mainEventHandler)); 165 | this.xgPlayer!.src = ''; 166 | this.xgPlayer?.off('ended', this.playNext); 167 | this.xgPlayer?.off('volumechange', this.updateVolumeConf); 168 | this.xgPlayer?.off('playbackrateChange', this.updateSpeedConf); 169 | this.xgPlayer?.destroy(); 170 | } 171 | 172 | descSources() { 173 | const eles = []; 174 | // @ts-ignore 175 | for (const [key] of this.sourceList) { 176 | eles.push( 177 | 178 | {this.descSeries(this.sourceList.get(key)!)} 179 | 180 | ); 181 | } 182 | return eles; 183 | } 184 | 185 | descSeries(playList: Map) { 186 | const eles = []; 187 | // @ts-ignore 188 | for (const [key] of playList) { 189 | eles.push( 190 | { 196 | if (this.state.curPlaySrc !== playList.get(key)) { 197 | this.setState({ 198 | curPlaySrc: playList.get(key), 199 | curPlayDrama: key 200 | }); 201 | this.xgPlayer!.currentTime = 0; 202 | this.xgPlayer!.src = playList.get(key)!; 203 | } 204 | }}> 205 | {key} 206 | 207 | ); 208 | } 209 | return eles; 210 | } 211 | 212 | render(): React.ReactNode { 213 | return ( 214 |
215 |
216 | { 218 | Control.go(-1); 219 | }}> 220 | 返回 221 | 222 | 223 | {this.controlState?.name} 224 | {this.state.isCollect ? ( 225 | 229 | ) : ( 230 | 234 | )} 235 | 236 | 237 | { 239 | ipcRenderer.send('WINDOW_MIN'); 240 | }} 241 | /> 242 | { 244 | ipcRenderer.send('WINDOW_MAX'); 245 | }} 246 | /> 247 | { 249 | ipcRenderer.send('WINDOW_CLOSE'); 250 | }} 251 | /> 252 | 253 |
254 |
255 |
256 |
257 | { 261 | this.selectedKey = newKey.includes('播放列表') 262 | ? newKey 263 | : this.selectedKey; 264 | }}> 265 | {this.descSources()} 266 | 267 |
269 | 273 |
274 |
275 | {this.controlState?.name} 276 |
277 |
278 | {this.controlState?.type && ( 279 |
280 | 类型:{this.controlState?.type} 281 |
282 | )} 283 | {this.controlState?.lang && ( 284 |
285 | 语言:{this.controlState?.lang} 286 |
287 | )} 288 | {this.controlState?.area && ( 289 |
290 | 地区:{this.controlState?.area} 291 |
292 | )} 293 | {this.controlState?.director && ( 294 |
295 | 导演:{this.controlState?.director} 296 |
297 | )} 298 | {this.controlState?.actor && ( 299 |
300 | 主演:{this.controlState?.actor} 301 |
302 | )} 303 |
304 |
305 |
306 |
307 |
308 | {this.controlState?.remark} 309 |
310 |
311 | 更新时间:{this.controlState?.updateTime} 312 |
313 |
简介
314 |
320 |
321 | 322 | 323 |
324 |
325 |
326 | ); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/renderer/views/recommend/recommend.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | color: #000; 3 | } 4 | 5 | .scroll-wrapper { 6 | height: 100%; 7 | width: 100%; 8 | overflow-x: hidden; 9 | overflow-y: auto; 10 | transform: translateZ(0); 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/views/recommend/recommend.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Button, Spin } from 'antd'; 3 | import store from '@/utils/store'; 4 | import InfiniteScroll from 'react-infinite-scroller'; 5 | import CustomSpin from '@/components/custom-spin/custom-spin'; 6 | import TomatoxWaterfall from '@/components/tomatox-waterfall/tomatox-waterfall'; 7 | import { filterResources } from '@/utils/filterResources'; 8 | import { queryResources } from '@/utils/request/modules/queryResources'; 9 | import cssM from './recommend.scss'; 10 | 11 | export default class Recommend extends React.Component { 12 | private page = 0; 13 | private pageCount = 10; 14 | private type = undefined; 15 | 16 | constructor(props: any) { 17 | super(props); 18 | this.state = { 19 | cardsData: [], 20 | recommendLoading: false 21 | }; 22 | } 23 | 24 | componentWillMount(): void { 25 | store.setState('GLOBAL_LOADING', true); 26 | this.initResource(); 27 | store.subscribe('SITE_ADDRESS', () => { 28 | this.page = 0; 29 | this.pageCount = 10; 30 | this.setState( 31 | { 32 | cardsData: [], 33 | recommendLoading: false 34 | }, 35 | this.initResource 36 | ); 37 | }); 38 | } 39 | 40 | initResource() { 41 | this.getRecommendLst(); 42 | } 43 | 44 | getRecommendLst() { 45 | if (this.page >= this.pageCount) { 46 | return; 47 | } 48 | Promise.all([ 49 | queryResources(++this.page, this.type, undefined, 24 * 30), 50 | queryResources(++this.page, this.type, undefined, 24 * 30), 51 | queryResources(++this.page, this.type, undefined, 24 * 30) 52 | ]).then( 53 | resLst => { 54 | const collectRes: IplayResource[] = []; 55 | resLst.forEach(res => { 56 | if (!res) { 57 | this.pageCount = 0; 58 | return; 59 | } 60 | const { list, pagecount } = res; 61 | this.pageCount = pagecount; 62 | collectRes.push(...list); 63 | }); 64 | if (store.getState('GLOBAL_LOADING')) { 65 | store.setState('GLOBAL_LOADING', false); 66 | } 67 | this.setState({ 68 | recommendLoading: this.page < this.pageCount, 69 | cardsData: [...this.state.cardsData, ...collectRes] 70 | }); 71 | }, 72 | reason => { 73 | if (store.getState('GLOBAL_LOADING')) { 74 | store.setState('GLOBAL_LOADING', false); 75 | } 76 | } 77 | ); 78 | } 79 | 80 | render(): React.ReactNode { 81 | return ( 82 |
83 | 89 | 90 |
91 | } 94 | spinning={this.state.recommendLoading} 95 | /> 96 |
97 |
98 |
99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/renderer/views/search/search.scss: -------------------------------------------------------------------------------- 1 | .no-result { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-content: center; 8 | text-align: center; 9 | color: #fff; 10 | span { 11 | margin-top: 10px; 12 | font-size: 15px; 13 | } 14 | img { 15 | width: 60px; 16 | } 17 | } 18 | 19 | .scroll-wrapper { 20 | height: 100%; 21 | width: 100%; 22 | overflow-y: auto; 23 | overflow-x: hidden; 24 | transform: translateZ(0); 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/views/search/search.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import TomatoxWaterfall from '@/components/tomatox-waterfall/tomatox-waterfall'; 3 | import store from '@/utils/store'; 4 | import TOMATOX_ICON from '@/images/svg/icon.svg'; 5 | import { queryResources, searchResources } from '@/utils/request/modules/queryResources'; 6 | import { filterResources } from '@/utils/filterResources'; 7 | import InfiniteScroll from 'react-infinite-scroller'; 8 | import { Spin } from 'antd'; 9 | import CustomSpin from '@/components/custom-spin/custom-spin'; 10 | import cssM from './search.scss'; 11 | import { getEnabledOrigin } from '@/utils/db/storage'; 12 | 13 | export default class Search extends React.Component { 14 | private page = 0; 15 | private pageCount = 10; 16 | constructor(props: any) { 17 | super(props); 18 | this.state = { 19 | cardsData: [], 20 | recommendLoading: false 21 | }; 22 | } 23 | 24 | componentWillMount(): void { 25 | store.subscribe('SEARCH_KEYWORDS', this.searchResByKW); 26 | const kw = store.getState('SEARCH_KEYWORDS'); 27 | if (kw) { 28 | this.searchResByKW(); 29 | } 30 | } 31 | 32 | componentWillUnmount(): void { 33 | store.unSubscribe('SEARCH_KEYWORDS', this.searchResByKW); 34 | } 35 | 36 | searchResByKW = async () => { 37 | this.page = 0; 38 | this.pageCount = 10; 39 | store.setState('GLOBAL_LOADING', true); 40 | this.setState( 41 | { 42 | cardsData: [], 43 | recommendLoading: false 44 | }, 45 | () => { 46 | this.searchWrapper(); 47 | } 48 | ); 49 | }; 50 | 51 | async searchWrapper() { 52 | if (this.page >= this.pageCount) { 53 | store.setState('GLOBAL_SEARCH_ENABLE', true); 54 | store.setState('GLOBAL_LOADING', false); 55 | this.setState({ 56 | recommendLoading: false 57 | }); 58 | return; 59 | } 60 | Promise.all([this.search(), this.search(), this.search()]).finally(() => { 61 | store.setState('GLOBAL_SEARCH_ENABLE', true); 62 | if (store.getState('GLOBAL_LOADING')) { 63 | this.setState({ 64 | recommendLoading: this.page < this.pageCount 65 | }); 66 | store.setState('GLOBAL_LOADING', false); 67 | } 68 | }); 69 | } 70 | 71 | async search() { 72 | const keyword = store.getState('SEARCH_KEYWORDS'); 73 | if (!keyword) { 74 | store.setState('GLOBAL_LOADING', false); 75 | return; 76 | } 77 | let res; 78 | if (getEnabledOrigin() === '默认') { 79 | res = await searchResources(++this.page, keyword); 80 | } else { 81 | res = await queryResources(++this.page, undefined, keyword); 82 | } 83 | if (!res) { 84 | this.pageCount = 0; 85 | return; 86 | } 87 | const { pagecount, list } = res; 88 | this.pageCount = pagecount; 89 | this.setState({ 90 | cardsData: [...this.state.cardsData, ...list] 91 | }); 92 | } 93 | 94 | render(): React.ReactNode { 95 | if (this.state.cardsData && this.state.cardsData.length) { 96 | return ( 97 |
98 | 104 | 105 | {this.state.recommendLoading && ( 106 |
107 | } 110 | spinning={this.state.recommendLoading} 111 | /> 112 |
113 | )} 114 |
115 |
116 | ); 117 | } 118 | return ( 119 |
120 |
121 | 122 |
123 | 暂无结果,请尝试搜索其他关键字 124 |
125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/renderer/views/setting/setting.scss: -------------------------------------------------------------------------------- 1 | .setting-wrapper { 2 | padding: 20px; 3 | height: calc(100vh - 50px); 4 | overflow-y: auto; 5 | } 6 | 7 | .setting-title { 8 | display: block; 9 | font-size: 18px; 10 | font-weight: bold; 11 | margin-bottom: 15px; 12 | } 13 | 14 | .setting-content { 15 | padding-left: 20px; 16 | margin-bottom: 15px; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: left; 20 | } 21 | 22 | .origins { 23 | padding-top: 10px !important; 24 | padding-bottom: 10px !important; 25 | span:nth-of-type(2) { 26 | width: 100%; 27 | } 28 | border-bottom: 1px solid rgba(142, 142, 142, 0.2); 29 | } 30 | 31 | .origin-item { 32 | white-space: nowrap; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | transition: all 0.3s ease-in-out; 36 | &:hover { 37 | color: #ff5c49; 38 | } 39 | } 40 | 41 | .origin-btn { 42 | cursor: pointer; 43 | transition: all 0.3s ease-in-out; 44 | &:hover { 45 | color: #ff5c49; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/views/setting/setting.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input, Radio, Row, Space, Col, Button, message, Checkbox } from 'antd'; 3 | import cssM from './setting.scss'; 4 | import { getEnabledOrigin, setEnabledOrigin } from '@/utils/db/storage'; 5 | import Indexed from '@/utils/db/indexed'; 6 | import { TABLES } from '@/utils/constants'; 7 | import store from '@/utils/store'; 8 | 9 | export default class Setting extends React.Component { 10 | constructor(props: any) { 11 | super(props); 12 | this.state = { 13 | enableOrigin: getEnabledOrigin(), 14 | selectableOrigins: [] 15 | }; 16 | } 17 | 18 | componentWillMount(): void { 19 | Indexed.instance!.queryAll(TABLES.TABLE_ORIGIN).then(res => { 20 | const result = res as Iorigin[]; 21 | result.sort((a, b) => a.addTime - b.addTime); 22 | this.setState({ 23 | selectableOrigins: result 24 | }); 25 | }); 26 | } 27 | 28 | private onChange = (e: Iorigin) => { 29 | if (e.id === this.state.enableOrigin) { 30 | return; 31 | } 32 | this.setState({ 33 | enableOrigin: e.id 34 | }); 35 | setEnabledOrigin(e.id); 36 | store.setState('SITE_ADDRESS', e); 37 | message.success('切换成功'); 38 | }; 39 | 40 | private deleteOrigin = (id: string) => { 41 | this.setState({ 42 | selectableOrigins: this.state.selectableOrigins.filter( 43 | (item: Iorigin) => item.id !== id 44 | ) 45 | }); 46 | Indexed.instance!.deleteById(TABLES.TABLE_ORIGIN, id); 47 | }; 48 | 49 | private addOrigin = () => { 50 | const name = (this.refs.oriNameInput as Input).state.value.trim(); 51 | const addr = (this.refs.oriAddrInput as Input).state.value.trim(); 52 | if (this.state.selectableOrigins.filter((item: Iorigin) => item.id === name).length) { 53 | message.warn('名称已存在'); 54 | } else if (!name || !/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(name)) { 55 | message.warn('名称不能为空且只能输入大小写字母和数字'); 56 | } else if (!addr || !/http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?/.test(addr)) { 57 | message.warn('地址不合法'); 58 | } else { 59 | const newOri: Iorigin = { id: name, api: addr, addTime: Date.now() }; 60 | Indexed.instance!.insertOrUpdateOrigin(TABLES.TABLE_ORIGIN, newOri); 61 | this.setState({ 62 | selectableOrigins: [...this.state.selectableOrigins, newOri] 63 | }); 64 | (this.refs.oriNameInput as Input).setValue(''); 65 | (this.refs.oriAddrInput as Input).setValue(''); 66 | } 67 | }; 68 | 69 | render(): React.ReactNode { 70 | return ( 71 |
72 | 视频源 73 |
74 | {this.state.selectableOrigins.map((item: Iorigin) => ( 75 | { 80 | this.onChange(item); 81 | }}> 82 | 83 | 84 | 名称:{item.id} 85 | 86 | 87 | 地址:{item.api} 88 | 89 | {item.id !== '默认' && this.state.enableOrigin !== item.id && ( 90 | 91 | { 94 | this.deleteOrigin(item.id); 95 | }}> 96 | 删除 97 | 98 | 99 | )} 100 | 101 | 102 | ))} 103 |
104 | 添加视频源 105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
118 |
119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /test/components/Counter.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as renderer from 'react-test-renderer'; 3 | 4 | import Counter from '../../src/renderer/components/Counter'; 5 | 6 | describe('Counter component', () => { 7 | it('renders correctly', () => { 8 | const tree = renderer 9 | .create() 10 | .toJSON(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/components/__snapshots__/Counter.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Counter component renders correctly 1`] = ` 4 |
7 |

8 | Red cube 12 |

13 |

16 | Current value: 17 | 1 18 |

19 |

20 | 26 | 32 |

33 |
34 | `; 35 | -------------------------------------------------------------------------------- /test/e2e/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { Application } from 'spectron'; 2 | import * as electronPath from 'electron'; 3 | import * as path from 'path'; 4 | 5 | jest.setTimeout(10000); 6 | 7 | describe('Main window', () => { 8 | let app: Application; 9 | 10 | beforeEach(() => { 11 | app = new Application({ 12 | path: electronPath.toString(), 13 | args: [path.join(__dirname, '..', '..')] 14 | }); 15 | 16 | return app.start(); 17 | }); 18 | 19 | afterEach(() => { 20 | if (app.isRunning()) { 21 | return app.stop(); 22 | } 23 | }); 24 | 25 | it('opens the window', async () => { 26 | const { client, browserWindow } = app; 27 | 28 | await client.waitUntilWindowLoaded(); 29 | const title = await browserWindow.getTitle(); 30 | 31 | expect(title).toBe('Webpack App'); 32 | }); 33 | 34 | it('increments the counter', async () => { 35 | const { client } = app; 36 | 37 | await client.waitUntilWindowLoaded(); 38 | await client.click('#increment'); 39 | 40 | const counterText = await client.getText('#counter-value'); 41 | 42 | expect(counterText).toBe('Current value: 1'); 43 | }); 44 | 45 | it('decrements the counter', async () => { 46 | const { client } = app; 47 | 48 | await client.waitUntilWindowLoaded(); 49 | await client.click('#decrement'); 50 | 51 | const counterText = await client.getText('#counter-value'); 52 | 53 | expect(counterText).toBe('Current value: -1'); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "outDir": "./dist", 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["src/renderer/*"] 26 | } 27 | }, 28 | "include": [ 29 | "./src", 30 | "./test", 31 | "./mocks" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /webpack.base.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: '[name].js' 10 | }, 11 | node: { 12 | __dirname: false, 13 | __filename: false 14 | }, 15 | resolve: { 16 | extensions: ['.tsx', '.ts', '.js', '.json'] 17 | }, 18 | // devtool: 'source-map', 19 | plugins: [ 20 | ] 21 | }; 22 | -------------------------------------------------------------------------------- /webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const merge = require('webpack-merge'); 4 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 5 | 6 | const baseConfig = require('./webpack.base.config'); 7 | 8 | module.exports = merge.smart(baseConfig, { 9 | target: 'electron-main', 10 | entry: { 11 | main: './src/main/main.ts' 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.tsx?$/, 17 | exclude: /node_modules/, 18 | loader: 'babel-loader', 19 | options: { 20 | cacheDirectory: true, 21 | babelrc: false, 22 | presets: [ 23 | [ 24 | '@babel/preset-env', 25 | { targets: 'maintained node versions' } 26 | ], 27 | '@babel/preset-typescript' 28 | ], 29 | plugins: [ 30 | ['@babel/plugin-proposal-class-properties', { loose: true }] 31 | ] 32 | } 33 | } 34 | ] 35 | }, 36 | plugins: [ 37 | new ForkTsCheckerWebpackPlugin({ 38 | reportFiles: ['src/main/**/*'] 39 | }), 40 | new webpack.DefinePlugin({ 41 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') 42 | }) 43 | ] 44 | }); 45 | -------------------------------------------------------------------------------- /webpack.main.prod.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | 3 | const baseConfig = require('./webpack.main.config'); 4 | 5 | module.exports = merge.smart(baseConfig, { 6 | mode: 'production' 7 | }); 8 | -------------------------------------------------------------------------------- /webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const merge = require('webpack-merge'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const InterpolateHtmlPlugin = require('interpolate-html-plugin'); 6 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 7 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 8 | const path = require('path') 9 | const baseConfig = require('./webpack.base.config'); 10 | 11 | module.exports = merge.smart(baseConfig, { 12 | target: 'electron-renderer', 13 | entry: { 14 | app: ['@babel/polyfill','./src/renderer/app.tsx'] 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | options: { 23 | cacheDirectory: true, 24 | babelrc: false, 25 | presets: [ 26 | [ 27 | '@babel/preset-env', 28 | { targets: { browsers: 'last 2 versions ' } } 29 | ], 30 | ], 31 | plugins: [ 32 | ['@babel/plugin-proposal-class-properties', { loose: true }] 33 | ] 34 | } 35 | }, 36 | { 37 | test: /\.tsx?$/, 38 | exclude: /node_modules/, 39 | loader: 'babel-loader', 40 | options: { 41 | cacheDirectory: true, 42 | babelrc: false, 43 | presets: [ 44 | [ 45 | '@babel/preset-env', 46 | { targets: { browsers: 'last 2 versions ' } } 47 | ], 48 | '@babel/preset-typescript', 49 | '@babel/preset-react' 50 | ], 51 | plugins: [ 52 | ['@babel/plugin-proposal-class-properties', { loose: true }] 53 | ] 54 | } 55 | }, 56 | { 57 | test: /\.scss$/, 58 | loaders: [ 59 | //'style-loader', 60 | { 61 | loader: MiniCssExtractPlugin.loader 62 | }, 63 | 'css-loader?modules&camelCase&localIdentName=[path][name]-[local]-[hash:5]', 64 | 'sass-loader' 65 | ] 66 | }, 67 | { 68 | // antd全局样式非模块化 69 | test: /\.css$/, 70 | loaders: [ 71 | //'style-loader', 72 | { 73 | loader: MiniCssExtractPlugin.loader 74 | }, 75 | 'css-loader' 76 | ] 77 | }, 78 | { 79 | test: /\.(gif|png|jpe?g|svg)$/, 80 | use: [ 81 | { 82 | loader: 'svg-url-loader', 83 | options: { 84 | encoding: 'base64' 85 | } 86 | } 87 | ] 88 | }, 89 | { 90 | test: /\.(ttf|eot|woff|woff2)$/, 91 | use: 'file-loader' 92 | } 93 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 94 | // { 95 | // enforce: 'pre', 96 | // test: /\.js$/, 97 | // loader: 'source-map-loader' 98 | // } 99 | ] 100 | }, 101 | plugins: [ 102 | new MiniCssExtractPlugin({ 103 | filename: '[name].css' 104 | }), 105 | new ForkTsCheckerWebpackPlugin({ 106 | reportFiles: ['src/renderer/**/*'] 107 | }), 108 | new webpack.NamedModulesPlugin(), 109 | new CopyWebpackPlugin([{ 110 | from: __dirname + '/public', 111 | to: __dirname + '/dist', 112 | ignore: 'index.html' 113 | }]), 114 | new HtmlWebpackPlugin({ 115 | template: __dirname + '/public/index.html' 116 | }), 117 | new InterpolateHtmlPlugin({ 118 | PUBLIC_URL: '.' // can modify `static` to another name or get it from `process` 119 | }), 120 | new webpack.DefinePlugin({ 121 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') 122 | }) 123 | ], 124 | resolve: { 125 | // 设置别名 126 | alias: { 127 | '@': path.resolve(__dirname, './src/renderer'), 128 | } 129 | } 130 | }); 131 | -------------------------------------------------------------------------------- /webpack.renderer.dev.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const spawn = require('child_process').spawn; 3 | 4 | const baseConfig = require('./webpack.renderer.config'); 5 | 6 | module.exports = merge.smart(baseConfig, { 7 | resolve: { 8 | alias: { 9 | 'react-dom': '@hot-loader/react-dom' 10 | } 11 | }, 12 | devServer: { 13 | port: 2003, 14 | compress: true, 15 | noInfo: true, 16 | stats: 'errors-only', 17 | inline: true, 18 | hot: true, 19 | headers: { 'Access-Control-Allow-Origin': '*' }, 20 | historyApiFallback: { 21 | verbose: true, 22 | disableDotRule: false 23 | }, 24 | before() { 25 | if (process.env.START_HOT) { 26 | console.log('Starting main process'); 27 | spawn('npm', ['run', 'start-main-dev'], { 28 | shell: true, 29 | env: process.env, 30 | stdio: 'inherit' 31 | }) 32 | .on('close', code => process.exit(code)) 33 | .on('error', spawnError => console.error(spawnError)); 34 | } 35 | } 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /webpack.renderer.prod.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | 3 | const baseConfig = require('./webpack.renderer.config'); 4 | 5 | module.exports = merge.smart(baseConfig, { 6 | mode: 'production' 7 | }); 8 | --------------------------------------------------------------------------------