├── .editorconfig ├── .eslintrc.js ├── .github ├── preview.png └── workflows │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .swcrc ├── LICENSE ├── README.md ├── app.config.js ├── bin ├── constants │ ├── colors.js │ └── index.js ├── modules │ ├── postbuild │ │ └── index.js │ └── release │ │ ├── index.js │ │ ├── utils │ │ ├── extractors.js │ │ └── index.js │ │ └── validations │ │ └── index.js └── utils │ ├── exec.js │ ├── index.js │ └── question.js ├── commitlint.config.js ├── docs ├── assets │ ├── download.svg │ ├── favicon.png │ ├── github.svg │ └── og.png ├── index.html └── styles.css ├── electron-builder.js ├── globals.d.ts ├── package.json ├── src ├── i18n │ ├── default.ts │ ├── en.ts │ ├── es.ts │ ├── index.ts │ └── pt-br.ts ├── main │ ├── factories │ │ ├── app │ │ │ ├── index.ts │ │ │ ├── instance.ts │ │ │ └── setup.ts │ │ ├── index.ts │ │ ├── ipcs │ │ │ └── register-window-creation.ts │ │ └── windows │ │ │ └── create.ts │ ├── index.ts │ ├── modules │ │ ├── index.ts │ │ ├── language │ │ │ ├── actions │ │ │ │ ├── get.ts │ │ │ │ └── set.ts │ │ │ └── index.ts │ │ ├── menu │ │ │ ├── actions │ │ │ │ ├── context.ts │ │ │ │ ├── tray.ts │ │ │ │ └── update.ts │ │ │ └── index.ts │ │ ├── screen │ │ │ ├── displays │ │ │ │ ├── adapters │ │ │ │ │ └── mac.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── shortcuts.ts │ │ ├── state │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── store │ │ │ ├── actions │ │ │ │ ├── merge.ts │ │ │ │ └── watch.ts │ │ │ └── index.ts │ │ ├── theme │ │ │ ├── actions │ │ │ │ └── set.ts │ │ │ └── index.ts │ │ └── window │ │ │ ├── index.ts │ │ │ └── ipcs │ │ │ └── size.ts │ └── windows │ │ ├── Main │ │ ├── index.ts │ │ └── ipcs │ │ │ ├── devices.ts │ │ │ └── index.ts │ │ └── index.ts ├── renderer │ ├── bridge │ │ ├── index.ts │ │ └── ipcs │ │ │ ├── devices │ │ │ ├── send.ts │ │ │ └── when │ │ │ │ ├── request-video-input-list.ts │ │ │ │ └── video-input-changes.ts │ │ │ ├── index.ts │ │ │ ├── languages.ts │ │ │ ├── on.ts │ │ │ ├── themes.ts │ │ │ └── window.ts │ ├── components │ │ ├── Camera │ │ │ └── index.tsx │ │ ├── Status │ │ │ └── index.tsx │ │ └── index.ts │ ├── hooks │ │ ├── index.ts │ │ ├── useCamera.ts │ │ ├── useLanguage.tsx │ │ ├── useLookForVideoInputDevices.ts │ │ ├── useMouseAutoHide.ts │ │ ├── useRoot.ts │ │ ├── useShapes.ts │ │ ├── useShortcuts.ts │ │ └── useTheme.ts │ ├── index.html │ ├── index.tsx │ ├── routes │ │ ├── index.tsx │ │ └── modules │ │ │ └── index.tsx │ ├── screens │ │ ├── Main │ │ │ └── index.tsx │ │ └── index.ts │ ├── store │ │ ├── config.ts │ │ └── index.ts │ └── styles │ │ ├── globals.css │ │ └── resets.css ├── resources │ └── icons │ │ ├── icon.icns │ │ ├── icon.ico │ │ ├── icon.png │ │ └── tray │ │ ├── trayTemplate.png │ │ └── trayTemplate@2x.png ├── shared │ ├── constants │ │ ├── environment.ts │ │ ├── index.ts │ │ ├── ipc.ts │ │ └── platform.ts │ ├── helpers │ │ ├── checkers.ts │ │ ├── converters.ts │ │ ├── index.ts │ │ └── keyboard │ │ │ ├── index.ts │ │ │ └── normalize.ts │ ├── i18n │ │ ├── factory.ts │ │ ├── main.ts │ │ └── renderer.ts │ ├── index.ts │ ├── store │ │ ├── default.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── themes │ │ ├── factory.ts │ │ ├── main.ts │ │ └── renderer.ts │ └── types │ │ └── index.ts └── themes │ ├── aura.ts │ ├── borderless.ts │ ├── default.ts │ ├── dusk.ts │ ├── index.ts │ ├── launchbase.ts │ ├── lemon.ts │ ├── omni.ts │ ├── rainbown.ts │ ├── scarlet.ts │ ├── turquoise.ts │ ├── vivid.ts │ └── yellow.ts ├── tsconfig.json ├── webpack ├── main.config.js ├── renderer.config.js ├── shared.config.js └── utils.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2020: true, 4 | node: true, 5 | }, 6 | extends: ['prettier', 'plugin:prettier/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint', 'prettier'], 13 | rules: { 14 | '@typescript-eslint/no-empty-interface': 0, 15 | 'prettier/prettier': [ 16 | 'error', 17 | { 18 | semi: false, 19 | singleQuote: true, 20 | tabWidth: 2, 21 | useTabs: false, 22 | trailingComma: 'es5', 23 | }, 24 | ], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /.github/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/.github/preview.png -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - "*" 7 | paths-ignore: 8 | - "*" 9 | 10 | jobs: 11 | code: 12 | name: Lint code 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup Node.js 17 | uses: actions/setup-node@master 18 | with: 19 | node-version: 14 20 | 21 | - name: Install dependencies 22 | uses: bahmutov/npm-install@v1 23 | 24 | - name: Run ESLint 25 | run: yarn lint -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: ["*"] 4 | 5 | jobs: 6 | release: 7 | if: startsWith(github.ref, 'refs/tags/v') 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | matrix: 12 | os: [macos-latest, ubuntu-latest, windows-latest] 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Install Node.js, NPM and Yarn 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 16 22 | 23 | - name: apt-update 24 | if: startsWith(matrix.os, 'ubuntu-latest') 25 | run: sudo apt-get update 26 | 27 | - name: autoremove 28 | if: startsWith(matrix.os, 'ubuntu-latest') 29 | run: sudo apt autoremove 30 | 31 | - name: Install libarchive rpm on Linux 32 | if: startsWith(matrix.os, 'ubuntu-latest') 33 | run: sudo apt-get install libarchive-tools rpm 34 | 35 | - name: Release Electron app 36 | uses: samuelmeuli/action-electron-builder@v1 37 | with: 38 | # GitHub token, automatically provided to the action 39 | # (No need to define this secret in the repo settings) 40 | github_token: ${{ secrets.github_token }} 41 | 42 | # If the commit is tagged with a version (e.g. "v1.0.0"), 43 | # release the app after building 44 | release: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | .eslintcache 20 | .swc 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true, 3 | "jsc": { 4 | "parser": { 5 | "target": "es2021", 6 | "syntax": "typescript", 7 | "jsx": true, 8 | "tsx": true, 9 | "dynamicImport": true, 10 | "allowJs": true 11 | }, 12 | "transform": { 13 | "react": { 14 | "pragma": "React.createElement", 15 | "pragmaFrag": "React.Fragment", 16 | "throwIfNamespace": true, 17 | "runtime": "automatic" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-* Mayk Brito 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Mini Video Me

6 |

A small webcam player focused on providing an easy way to add and control your webcam during recordings.

7 | 8 |

9 | 12 | 13 | 14 | 15 | releases url 16 | 17 | 18 | 19 | 20 | License: MIT 21 | 22 | 23 | 24 | 25 | Twitter: maykbrito 26 | 27 |

28 | 29 |
30 | 31 | # Features 32 | 33 | - 👨‍🚀 Shortcuts 34 | - ⚙️ Highly configurable 35 | - 💅 Themes and custom themes 36 | - 🚀 Cross-platform (Windows, Mac, Linux) 37 | - 🌎 Languages and custom languages (i18n) 38 | 39 | Sample preview running the app showing Diego Fernandes happy on the app screen with Visual Studio Code open in the background 40 | 41 | # Installation 42 | 43 | - [Download](https://github.com/maykbrito/mini-video-me/releases) 44 | 45 | ⚠️ **For MacOS**, move the extracted app to the **Applications** folder, open your terminal and run the following command to sign the app 46 | ```bash 47 | codesign --force --deep --sign - /Applications/Mini\ Video\ Me.app 48 | ``` 49 | Wait until the command finishes, then open the app and allow the camera permissions asked you. 🚀 50 | 51 | # Usage & settings 52 | 53 | After running for the first time you can access the app settings through the **tray menu** and click on `Settings` to change default shortcuts, camera size, zoom, shapes, themes, languages etc. 54 | 55 | ## Default shortcuts 56 | 57 | > ⚠️ **For Linux/Windows users:** if you're not new to Mini Video Me, you'll probably need to update the shortcuts manually. For this, open the camera settings in `tray menu` > `Settings` and update it with the new default shortcuts or others that you like, and the operating system allows, or just select `Reset Settings`. 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 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 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 |
MacOSLinux / WindowsFunctionWindow must be focused
+ / -+ / -Zoom in/outYes
//Flip horizontalYes
ooToggle custom shapesYes
rrReset zoomYes
BackspaceBackspaceSwitch camYes
SpaceSpaceToggle window size (small/large)Yes
Command + ,Ctrl + ,Open the settings fileYes
Arrow Up / Down / Left / RightArrow Up / Down / Left / RightAdjust video offsetYes
Command + Shift + Alt + UpShift + Alt + UpMove camera to upper screen edgeNo
Command + Shift + Alt + DownShift + Alt + DownMove camera to lower screen edgeNo
Command + Shift + Alt + RightShift + Alt + RightMove camera to right screen edgeNo
Command + Shift + Alt + 1Shift + Alt + 1Toggle camera size (small/large)No
Command + Shift + Alt + 3Shift + Alt + 2Toggle camera visibility (show/hide)No
149 | 150 | ## Adjusting the border 151 | 152 | Open the camera settings in `tray menu` > `Settings`, then look for `"themeOverrides"` and change `"borderWith"` property to `"0"` if you should to **remove the border**. Or you can make it thick by changing the value above to `"10px"` for example. 153 | 154 | ## Using custom shapes 155 | 156 | You can use custom shapes using the [`clip-path`](https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path) 157 | CSS property. You can use a tool like [Clippy](https://bennettfeely.com/clippy/) to play around with different shapes 158 | you can build with `clip-path`. 159 | 160 | ### How to add/remove shapes 161 | 162 | Open the camera settings in `tray menu` > `Settings` and in the `"shapes"` property, place the CSS's clip-path value as you wish. 163 | 164 |
165 | See this image example 166 | 167 |
168 | 169 | ## Change size 170 | 171 | Open the camera settings in `tray menu` > `Settings` and change `"screen.initial"` and/or `"screen.large"`'s width and height properties as you wish 172 | 173 |
174 | See this image example 175 | 176 |
177 | 178 | # Contributing 179 | 180 | Clone de repository, open its folder and install dependencies with: 181 | 182 | ```sh 183 | yarn 184 | ``` 185 | 186 | Run it using: 187 | 188 | ```sh 189 | yarn dev 190 | ``` 191 | 192 | # Author 193 | 194 | 👤 **Mayk Brito** 195 | 196 | - Twitter: [@maykbrito](https://twitter.com/maykbrito) 197 | - Github: [@maykbrito](https://github.com/maykbrito) 198 | - LinkedIn: [@maykbrito](https://linkedin.com/in/maykbrito) 199 | 200 | ## Show your support 201 | 202 | Give a ⭐️ if this project helped you! 203 | -------------------------------------------------------------------------------- /app.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | devServer, 3 | devTempBuildFolder, 4 | name: NAME, 5 | author: AUTHOR, 6 | version: VERSION, 7 | displayName: TITLE, 8 | description: DESCRIPTION, 9 | } = require('./package.json') 10 | 11 | exports.APP_CONFIG = { 12 | NAME, 13 | TITLE, 14 | AUTHOR, 15 | VERSION, 16 | DESCRIPTION, 17 | 18 | MAIN: { 19 | WINDOW: { 20 | WIDTH: 700, 21 | HEIGHT: 473, 22 | }, 23 | }, 24 | 25 | RENDERER: { 26 | DEV_SERVER: { 27 | URL: devServer, 28 | }, 29 | }, 30 | 31 | FOLDERS: { 32 | ENTRY_POINTS: { 33 | MAIN: './src/main/index.ts', 34 | BRIDGE: './src/renderer/bridge/index.ts', 35 | RENDERER: './src/renderer/index.tsx', 36 | }, 37 | 38 | INDEX_HTML: 'src/renderer/index.html', 39 | RESOURCES: 'src/resources', 40 | DEV_TEMP_BUILD: devTempBuildFolder, 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /bin/constants/colors.js: -------------------------------------------------------------------------------- 1 | exports.COLORS = { 2 | RED: '\x1b[31m', 3 | RESET: '\x1b[0m', 4 | GRAY: '\x1b[90m', 5 | BLUE: '\x1b[34m', 6 | CYAN: '\x1b[36m', 7 | GREEN: '\x1b[32m', 8 | WHITE: '\x1b[37m', 9 | YELLOW: '\x1b[33m', 10 | MAGENTA: '\x1b[35m', 11 | LIGHT_GRAY: '\x1b[37m', 12 | SOFT_GRAY: '\x1b[38;5;244m', 13 | } 14 | -------------------------------------------------------------------------------- /bin/constants/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./colors'), 3 | } 4 | -------------------------------------------------------------------------------- /bin/modules/postbuild/index.js: -------------------------------------------------------------------------------- 1 | const { writeFile } = require('fs/promises') 2 | const { resolve } = require('path') 3 | 4 | const packageJSON = require('../../../package.json') 5 | 6 | async function createPackageJSONDistVersion() { 7 | const { 8 | main, 9 | scripts, 10 | devDependencies, 11 | devTempBuildFolder, 12 | ...restOfPackageJSON 13 | } = packageJSON 14 | 15 | const packageJSONDistVersion = { 16 | main: main?.split('/')?.reverse()?.[0] || 'index.js', 17 | ...restOfPackageJSON, 18 | } 19 | 20 | try { 21 | await writeFile( 22 | resolve(devTempBuildFolder, 'package.json'), 23 | JSON.stringify(packageJSONDistVersion, null, 2) 24 | ) 25 | } catch ({ message }) { 26 | console.log(` 27 | 🛑 Something went wrong!\n 28 | 🧐 There was a problem creating the package.json dist version...\n 29 | 👀 Error: ${message} 30 | `) 31 | } 32 | } 33 | 34 | createPackageJSONDistVersion() 35 | -------------------------------------------------------------------------------- /bin/modules/release/index.js: -------------------------------------------------------------------------------- 1 | const { writeFile } = require('fs/promises') 2 | const { resolve } = require('path') 3 | const open = require('open') 4 | 5 | const { extractOwnerAndRepoFromGitRemoteURL } = require('./utils') 6 | const { checkValidations } = require('./validations') 7 | const packageJSON = require('../../../package.json') 8 | const { question, exec } = require('../../utils') 9 | const { COLORS } = require('../../constants') 10 | 11 | async function makeRelease() { 12 | console.clear() 13 | 14 | const { version } = packageJSON 15 | 16 | const newVersion = await question( 17 | `Enter a new version: ${COLORS.SOFT_GRAY}(current is ${version})${COLORS.RESET} ` 18 | ) 19 | 20 | if (checkValidations({ version, newVersion })) { 21 | return 22 | } 23 | 24 | packageJSON.version = newVersion 25 | 26 | try { 27 | console.log( 28 | `${COLORS.CYAN}> Updating package.json version...${COLORS.RESET}` 29 | ) 30 | 31 | await writeFile( 32 | resolve('package.json'), 33 | JSON.stringify(packageJSON, null, 2) 34 | ) 35 | 36 | console.log(`\n${COLORS.GREEN}Done!${COLORS.RESET}\n`) 37 | console.log(`${COLORS.CYAN}> Trying to release it...${COLORS.RESET}`) 38 | 39 | exec( 40 | [ 41 | `git commit -am v${newVersion}`, 42 | `git tag v${newVersion}`, 43 | `git push`, 44 | `git push --tags`, 45 | ], 46 | { 47 | inherit: true, 48 | } 49 | ) 50 | 51 | const [repository] = exec([`git remote get-url --push origin`]) 52 | const ownerAndRepo = extractOwnerAndRepoFromGitRemoteURL(repository) 53 | 54 | console.log( 55 | `${COLORS.CYAN}> Opening the repository releases page...${COLORS.RESET}` 56 | ) 57 | 58 | await open(`https://github.com/${ownerAndRepo}/releases`) 59 | 60 | console.log( 61 | `${COLORS.CYAN}> Opening the repository actions page...${COLORS.RESET}` 62 | ) 63 | 64 | await open(`https://github.com/${ownerAndRepo}/actions`) 65 | 66 | console.log(`\n${COLORS.GREEN}Done!${COLORS.RESET}\n`) 67 | } catch ({ message }) { 68 | console.log(` 69 | 🛑 Something went wrong!\n 70 | 👀 Error: ${message} 71 | `) 72 | } 73 | } 74 | 75 | makeRelease() 76 | -------------------------------------------------------------------------------- /bin/modules/release/utils/extractors.js: -------------------------------------------------------------------------------- 1 | exports.extractOwnerAndRepoFromGitRemoteURL = (url) => { 2 | return url 3 | ?.replace(/^git@github.com:|.git$/gims, '') 4 | ?.replace(/^https:\/\/github.com\/|.git$/gims, '') 5 | ?.trim() 6 | } 7 | -------------------------------------------------------------------------------- /bin/modules/release/utils/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./extractors'), 3 | } 4 | -------------------------------------------------------------------------------- /bin/modules/release/validations/index.js: -------------------------------------------------------------------------------- 1 | const semver = require('semver') 2 | 3 | const { COLORS } = require('../../../constants') 4 | 5 | exports.checkValidations = ({ version, newVersion }) => { 6 | if (!newVersion) { 7 | console.log(`${COLORS.RED}No version entered${COLORS.RESET}`) 8 | 9 | return true 10 | } 11 | 12 | if (!semver.valid(newVersion)) { 13 | console.log( 14 | `${COLORS.RED}Version must have a semver format (${COLORS.SOFT_GRAY}x.x.x${COLORS.RESET} example: ${COLORS.GREEN}1.0.1${COLORS.RESET}${COLORS.RED})${COLORS.RESET}` 15 | ) 16 | 17 | return true 18 | } 19 | 20 | if (semver.ltr(newVersion, version)) { 21 | console.log( 22 | `${COLORS.RED}New version is lower than current version${COLORS.RESET}` 23 | ) 24 | 25 | return true 26 | } 27 | 28 | if (semver.eq(newVersion, version)) { 29 | console.log( 30 | `${COLORS.RED}New version is equal to current version${COLORS.RESET}` 31 | ) 32 | 33 | return true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /bin/utils/exec.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process') 2 | const { resolve } = require('path') 3 | 4 | function makeOptions(options) { 5 | return { 6 | stdio: options?.inherit ? 'inherit' : 'pipe', 7 | cwd: resolve(), 8 | encoding: 'utf8', 9 | } 10 | } 11 | 12 | exports.exec = (commands, options) => { 13 | const outputs = [] 14 | 15 | for (const command of commands) { 16 | const output = execSync(command, makeOptions(options)) 17 | outputs.push(output) 18 | } 19 | 20 | return outputs 21 | } 22 | -------------------------------------------------------------------------------- /bin/utils/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./question'), 3 | ...require('./exec'), 4 | } 5 | -------------------------------------------------------------------------------- /bin/utils/question.js: -------------------------------------------------------------------------------- 1 | const Readline = require('readline') 2 | 3 | exports.question = (question) => { 4 | const readline = Readline.createInterface({ 5 | input: process.stdin, 6 | output: process.stdout, 7 | }) 8 | 9 | return new Promise((resolve) => { 10 | readline.question(question, (answer) => { 11 | readline.close() 12 | resolve(answer) 13 | }) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /docs/assets/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/assets/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/docs/assets/og.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Mini Video Me 32 | 33 | 34 |
35 |

Mini Video Me

36 |

A small webcam player focused on providing an easy way to add and control your webcam during recordings.

37 | 55 |
56 | 57 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | background: #000; 9 | color: #FFF; 10 | } 11 | 12 | body, button { 13 | font-family: Inter, sans-serif; 14 | } 15 | 16 | #wrapper { 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | flex-direction: column; 21 | height: 100vh; 22 | } 23 | 24 | @keyframes gradient { 25 | 0% { 26 | background-position: 0% 50%; 27 | } 28 | 50% { 29 | background-position: 100% 50%; 30 | } 31 | 100% { 32 | background-position: 0% 50%; 33 | } 34 | } 35 | 36 | h1 { 37 | font-weight: 800; 38 | font-size: 92px; 39 | line-height: 92px; 40 | text-align: center; 41 | letter-spacing: -5.85133px; 42 | background-image: linear-gradient(45deg, #988BC7, #FF79C6); 43 | background-clip: text; 44 | padding: 0 24px; 45 | 46 | background-size: 200%; 47 | 48 | animation: gradient 6s ease infinite; 49 | 50 | -webkit-background-clip: text; 51 | -webkit-text-fill-color: transparent; 52 | } 53 | 54 | p { 55 | font-size: 24px; 56 | line-height: 36px; 57 | margin: 24px; 58 | max-width: 700px; 59 | text-align: center; 60 | } 61 | 62 | footer { 63 | width: 100%; 64 | display: flex; 65 | padding: 0 24px; 66 | align-items: center; 67 | justify-content: center; 68 | } 69 | 70 | footer a { 71 | display: inline-block; 72 | height: 50px; 73 | padding: 0 24px; 74 | 75 | display: flex; 76 | align-items: center; 77 | color: #FFF; 78 | 79 | border-radius: 4px; 80 | 81 | text-decoration: none; 82 | 83 | transition: filter 0.2s; 84 | } 85 | 86 | footer a span { 87 | flex: 1; 88 | padding-left: 16px; 89 | text-align: left; 90 | } 91 | 92 | footer a.download { 93 | background: #8257e6; 94 | } 95 | 96 | footer a.github { 97 | border: 1px solid #FFF; 98 | margin-left: 16px; 99 | } 100 | 101 | footer a:hover { 102 | filter: brightness(0.8); 103 | } 104 | 105 | 106 | @media (max-width: 700px) { 107 | h1 { 108 | font-size: 54px; 109 | line-height: 54px; 110 | letter-spacing: -3px; 111 | } 112 | 113 | p { 114 | font-size: 20px; 115 | line-height: 32px; 116 | } 117 | 118 | footer a { 119 | padding: 0 16px; 120 | } 121 | 122 | footer a.github { 123 | width: 58px; 124 | } 125 | 126 | footer a.github span { 127 | display: none; 128 | } 129 | } -------------------------------------------------------------------------------- /electron-builder.js: -------------------------------------------------------------------------------- 1 | const { APP_CONFIG } = require('./app.config') 2 | 3 | const { NAME, AUTHOR, TITLE, DESCRIPTION, FOLDERS } = APP_CONFIG 4 | 5 | const CURRENT_YEAR = new Date().getFullYear() 6 | const AUTHOR_IN_KEBAB_CASE = AUTHOR.name.replace(/\s+/g, '-') 7 | const APP_ID = `com.${AUTHOR_IN_KEBAB_CASE}.${NAME}`.toLowerCase() 8 | 9 | module.exports = { 10 | appId: APP_ID, 11 | productName: TITLE, 12 | copyright: `Copyright © ${CURRENT_YEAR} — ${AUTHOR.name}`, 13 | 14 | directories: { 15 | app: FOLDERS.DEV_TEMP_BUILD, 16 | output: 'dist', 17 | }, 18 | 19 | mac: { 20 | icon: `${FOLDERS.RESOURCES}/icons/icon.icns`, 21 | category: 'public.app-category.utilities', 22 | }, 23 | 24 | dmg: { 25 | icon: false, 26 | }, 27 | 28 | linux: { 29 | category: 'Utilities', 30 | synopsis: DESCRIPTION, 31 | target: ['AppImage', 'deb', 'pacman', 'freebsd', 'rpm'], 32 | }, 33 | 34 | win: { 35 | icon: `${FOLDERS.RESOURCES}/icons/icon.ico`, 36 | target: ['nsis', 'portable', 'zip'], 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' 2 | declare module '*.scss' 3 | declare module '*.sass' 4 | declare module '*.jpeg' 5 | declare module '*.jpg' 6 | declare module '*.png' 7 | declare module '*.svg' 8 | declare module '*.gif' 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "Mini Video Me", 3 | "name": "mini-video-me", 4 | "description": "A small webcam player focused on providing an easy way to add and control your webcam during recordings.", 5 | "version": "4.0.2", 6 | "main": "./node_modules/.dev-temp-build/main.js", 7 | "devTempBuildFolder": "./node_modules/.dev-temp-build", 8 | "devServer": "http://localhost:4927", 9 | "author": { 10 | "name": "Mayk Brito", 11 | "email": "maykbrito@gmail.com" 12 | }, 13 | "license": "MIT", 14 | "keywords": [ 15 | "webcam", 16 | "app", 17 | "mini video me", 18 | "video" 19 | ], 20 | "scripts": { 21 | "start": "electron .", 22 | "build": "cross-env NODE_ENV=production yarn r clean webpack:main webpack:renderer", 23 | "postbuild": "node ./bin/modules/postbuild/index.js", 24 | "predev": "cross-var \"rimraf $npm_package_devTempBuildFolder\"", 25 | "dev": "concurrently \"cross-env BROWSER=none yarn dev:renderer\" \"yarn r server:wait nodemon:start\"", 26 | "dev:main": "cross-env NODE_ENV=development yarn r webpack:main start", 27 | "dev:renderer": "cross-env NODE_ENV=development yarn r webpack:renderer webpack:serve", 28 | "postinstall": "yarn r build install:deps", 29 | "install:deps": "electron-builder install-app-deps", 30 | "make:release": "node ./bin/modules/release/index.js", 31 | "webpack:main": "webpack --config ./webpack/main.config.js", 32 | "webpack:renderer": "webpack --config ./webpack/renderer.config.js", 33 | "webpack:serve": "webpack serve --config ./webpack/renderer.config.js", 34 | "server:wait": "cross-var \"wait-on $npm_package_devServer\"", 35 | "nodemon:start": "nodemon --watch src/main --watch src/extensions/**/*.ts --watch src/renderer/bridge --watch src/shared --ignore src/resources --ext \"*\" --exec yarn dev:main", 36 | "predist": "yarn build", 37 | "dist": "electron-builder", 38 | "release": "electron-builder --publish always", 39 | "clean": "rimraf dist", 40 | "prepare": "husky install", 41 | "r": "yarn run-s", 42 | "lint": "eslint . --ext js,ts" 43 | }, 44 | "dependencies": { 45 | "ajv": "^8.5.0", 46 | "chokidar": "^3.5.3", 47 | "electron-store": "^8.0.1", 48 | "json-schema-typed": "^7.0.3", 49 | "lodash.merge": "^4.6.2", 50 | "react": "^17.0.2", 51 | "react-dom": "^17.0.2", 52 | "react-router-dom": "^6.2.1" 53 | }, 54 | "devDependencies": { 55 | "@commitlint/cli": "^16.2.1", 56 | "@commitlint/config-conventional": "^16.2.1", 57 | "@svgr/webpack": "^6.2.1", 58 | "@swc/cli": "^0.1.55", 59 | "@swc/core": "^1.2.136", 60 | "@types/lodash.merge": "^4.6.7", 61 | "@types/node": "^17.0.15", 62 | "@types/react": "^17.0.39", 63 | "@types/react-dom": "^17.0.11", 64 | "@types/webpack-env": "^1.16.3", 65 | "@typescript-eslint/eslint-plugin": "^5.10.2", 66 | "@typescript-eslint/parser": "^5.10.2", 67 | "concurrently": "^7.0.0", 68 | "copy-webpack-plugin": "^10.2.4", 69 | "cross-env": "^7.0.3", 70 | "cross-var": "^1.1.0", 71 | "css-loader": "^6.6.0", 72 | "electron": "^18.2.0", 73 | "electron-builder": "^23.0.3", 74 | "electron-devtools-installer": "^3.2.0", 75 | "electron-react-devtools": "^0.5.3", 76 | "eslint": "^8.8.0", 77 | "eslint-config-prettier": "^8.3.0", 78 | "eslint-plugin-node": "^11.1.0", 79 | "eslint-plugin-prettier": "^4.0.0", 80 | "file-loader": "^6.2.0", 81 | "html-webpack-plugin": "^5.5.0", 82 | "husky": "^7.0.4", 83 | "lint-staged": ">=10", 84 | "nodemon": "^2.0.15", 85 | "npm-run-all": "^4.1.5", 86 | "open": "^8.4.0", 87 | "prettier": "^2.5.1", 88 | "rimraf": "^3.0.2", 89 | "sass-loader": "^12.4.0", 90 | "semver": "^7.3.5", 91 | "simple-progress-webpack-plugin": "^2.0.0", 92 | "style-loader": "^3.3.1", 93 | "swc-loader": "^0.1.15", 94 | "tsconfig-paths-webpack-plugin": "^3.5.2", 95 | "typescript": "^4.5.5", 96 | "typescript-plugin-css-modules": "^3.4.0", 97 | "wait-on": "^6.0.0", 98 | "webpack": "^5.72.0", 99 | "webpack-cli": "^4.9.2", 100 | "webpack-dev-server": "^4.7.4" 101 | }, 102 | "lint-staged": { 103 | "*.{js,ts}": [ 104 | "eslint --quiet --fix" 105 | ] 106 | }, 107 | "eslintIgnore": [ 108 | "dist" 109 | ] 110 | } -------------------------------------------------------------------------------- /src/i18n/default.ts: -------------------------------------------------------------------------------- 1 | import { englishLanguage } from './en' 2 | 3 | export const defaultLanguage = { 4 | ...englishLanguage, 5 | id: 'default', 6 | } 7 | -------------------------------------------------------------------------------- /src/i18n/en.ts: -------------------------------------------------------------------------------- 1 | export const englishLanguage = { 2 | id: 'en', 3 | displayName: 'English', 4 | 5 | dictionary: { 6 | default: 'Default', 7 | language: 'Languages', 8 | topLeft: 'Top Left', 9 | topRight: 'Top Right', 10 | bottomLeft: 'Bottom Left', 11 | bottomRight: 'Bottom Right', 12 | small: 'Small', 13 | large: 'Large', 14 | settings: 'Settings', 15 | resetSettings: 'Reset Settings', 16 | resetSettingsConfirmation: 'Are you sure you want to reset the settings?', 17 | themes: 'Themes', 18 | windowSize: 'Window Sizes', 19 | screenEdge: 'Screen Edges', 20 | display: 'Display', 21 | displays: 'Displays', 22 | videoInputSource: 'Video Input Sources', 23 | close: 'Close', 24 | loading: 'Camera loading', 25 | connected: 'Camera connected', 26 | disconnected: 'Camera disconnected', 27 | notFound: 'Camera not found', 28 | yes: 'Yes', 29 | no: 'No', 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /src/i18n/es.ts: -------------------------------------------------------------------------------- 1 | export const spanishLanguage = { 2 | id: 'es', 3 | displayName: 'Español', 4 | 5 | dictionary: { 6 | default: 'Estándar', 7 | language: 'Idiomas', 8 | topLeft: 'Arriba Izquierda', 9 | topRight: 'Arriba Derecha', 10 | bottomLeft: 'Inferior Izquierda', 11 | bottomRight: 'Inferior Derecha', 12 | small: 'Pequeña', 13 | large: 'Grande', 14 | settings: 'Preferencias', 15 | resetSettings: 'Reiniciar Preferencias', 16 | resetSettingsConfirmation: 17 | '¿Estás seguro de que quieres reiniciar las preferencias?', 18 | themes: 'Temas', 19 | windowSize: 'Tamaños de Ventanas', 20 | screenEdge: 'Bordes de la Pantalla', 21 | display: 'Pantalla', 22 | displays: 'Pantallas', 23 | videoInputSource: 'Fuentes de Vídeo', 24 | close: 'Cerrar', 25 | loading: 'Cargando la cámara', 26 | connected: 'Conectar la cámara', 27 | disconnected: 'Cámara desconectada', 28 | notFound: 'Cámara no encontrada', 29 | yes: 'Sí', 30 | no: 'No', 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from './default' 2 | export * from './pt-br' 3 | export * from './es' 4 | -------------------------------------------------------------------------------- /src/i18n/pt-br.ts: -------------------------------------------------------------------------------- 1 | export const brazilianPortugueseLanguage = { 2 | id: 'pt_br', 3 | displayName: 'Português (Brasil)', 4 | 5 | dictionary: { 6 | default: 'Padrão', 7 | language: 'Idiomas', 8 | topLeft: 'Superior Esquerda', 9 | topRight: 'Superior Direita', 10 | bottomLeft: 'Inferior Esquerda', 11 | bottomRight: 'Inferior Direita', 12 | small: 'Pequena', 13 | large: 'Grande', 14 | settings: 'Preferências', 15 | resetSettings: 'Resetar Preferências', 16 | resetSettingsConfirmation: 17 | 'Você tem certeza que deseja resetar as preferências?', 18 | themes: 'Temas', 19 | windowSize: 'Tamanhos da Janela', 20 | screenEdge: 'Extremidades da Tela', 21 | display: 'Tela', 22 | displays: 'Telas', 23 | videoInputSource: 'Fontes de Vídeo', 24 | close: 'Fechar', 25 | loading: 'Carregando a câmera', 26 | connected: 'Conectando a câmera', 27 | disconnected: 'Câmera desconectada', 28 | notFound: 'Câmera não encontrada', 29 | yes: 'Sim', 30 | no: 'Não', 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /src/main/factories/app/index.ts: -------------------------------------------------------------------------------- 1 | export * from './instance' 2 | export * from './setup' 3 | -------------------------------------------------------------------------------- /src/main/factories/app/instance.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | 3 | export function makeAppWithSingleInstanceLock(fn: () => void) { 4 | const isPrimaryInstance = app.requestSingleInstanceLock() 5 | 6 | !isPrimaryInstance ? app.quit() : fn() 7 | } 8 | -------------------------------------------------------------------------------- /src/main/factories/app/setup.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | 3 | import installExtension, { 4 | REACT_DEVELOPER_TOOLS, 5 | } from 'electron-devtools-installer' 6 | 7 | import { PLATFORM, ENVIRONMENT } from 'shared/constants' 8 | 9 | export async function makeAppSetup(createWindow: () => Promise) { 10 | if (ENVIRONMENT.IS_DEV) { 11 | await installExtension(REACT_DEVELOPER_TOOLS, { 12 | forceDownload: false, 13 | }) 14 | } 15 | 16 | let window = await createWindow() 17 | 18 | app.on('activate', async () => 19 | !BrowserWindow.getAllWindows().length 20 | ? (window = await createWindow()) 21 | : BrowserWindow.getAllWindows() 22 | ?.reverse() 23 | .forEach((window) => window.restore()) 24 | ) 25 | 26 | app.on('web-contents-created', (_, contents) => 27 | contents.on( 28 | 'will-navigate', 29 | (event, _) => !ENVIRONMENT.IS_DEV && event.preventDefault() 30 | ) 31 | ) 32 | 33 | app.on('window-all-closed', () => !PLATFORM.IS_MAC && app.quit()) 34 | 35 | return window 36 | } 37 | 38 | PLATFORM.IS_LINUX && app.disableHardwareAcceleration() 39 | 40 | app.commandLine.appendSwitch('force-color-profile', 'srgb') 41 | 42 | process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' 43 | delete process.env.ELECTRON_ENABLE_SECURITY_WARNINGS 44 | -------------------------------------------------------------------------------- /src/main/factories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ipcs/register-window-creation' 2 | export * from './windows/create' 3 | export * from './app' 4 | -------------------------------------------------------------------------------- /src/main/factories/ipcs/register-window-creation.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | 3 | import { WindowCreationByIPC, BrowserWindowOrNull } from 'shared/types' 4 | 5 | export function registerWindowCreationByIPC({ 6 | channel, 7 | callback, 8 | window: createWindow, 9 | }: WindowCreationByIPC) { 10 | let window: BrowserWindowOrNull 11 | 12 | ipcMain.handle(channel, (event) => { 13 | if (!createWindow || window) return 14 | 15 | window = createWindow() 16 | 17 | window.on('closed', () => (window = null)) 18 | 19 | callback && callback(window, event) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/main/factories/windows/create.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | 3 | import { ENVIRONMENT } from 'shared/constants' 4 | import { WindowProps } from 'shared/types' 5 | import { APP_CONFIG } from '~/app.config' 6 | 7 | export function createWindow({ id, ...settings }: WindowProps) { 8 | const window = new BrowserWindow(settings) 9 | const devServerURL = `${APP_CONFIG.RENDERER.DEV_SERVER.URL}#/${id}` 10 | 11 | ENVIRONMENT.IS_DEV 12 | ? window.loadURL(devServerURL) 13 | : window.loadFile('index.html', { 14 | hash: `/${id}`, 15 | }) 16 | 17 | window.on('closed', window.destroy) 18 | 19 | return window 20 | } 21 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | 3 | import { makeAppSetup, makeAppWithSingleInstanceLock } from './factories' 4 | import { MainWindow } from './windows' 5 | 6 | makeAppWithSingleInstanceLock(async () => { 7 | await app.whenReady() 8 | await makeAppSetup(MainWindow) 9 | }) 10 | -------------------------------------------------------------------------------- /src/main/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shortcuts' 2 | export * from './language' 3 | export * from './screen' 4 | export * from './window' 5 | export * from './state' 6 | export * from './store' 7 | export * from './menu' 8 | -------------------------------------------------------------------------------- /src/main/modules/language/actions/get.ts: -------------------------------------------------------------------------------- 1 | import { languages, defaultLanguage } from 'shared/i18n/main' 2 | import { userPreferences } from 'shared/store' 3 | 4 | export function getLanguage() { 5 | const languageId = userPreferences.get('language') 6 | 7 | const language = 8 | languages.find(({ id }) => id === languageId) || defaultLanguage 9 | 10 | return language 11 | } 12 | -------------------------------------------------------------------------------- /src/main/modules/language/actions/set.ts: -------------------------------------------------------------------------------- 1 | import { getVirtualState } from 'main/modules/state' 2 | import { userPreferences } from 'shared/store' 3 | import { IPC } from 'shared/constants' 4 | 5 | export function setLanguage(id: string) { 6 | const { mainWindow } = getVirtualState() 7 | 8 | userPreferences.set('language', id) 9 | mainWindow.webContents.send(IPC.LANGUAGES.UPDATE, id) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/modules/language/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions/get' 2 | export * from './actions/set' 3 | -------------------------------------------------------------------------------- /src/main/modules/menu/actions/context.ts: -------------------------------------------------------------------------------- 1 | import { Menu, dialog } from 'electron' 2 | import { join } from 'path' 3 | 4 | import { setIfStoreShouldRestartWhenItChanges } from '../../store' 5 | import { getDefaultStore, userPreferences } from 'shared/store' 6 | import { ScreenEdge, ScreenSize } from 'main/modules/screen' 7 | import { languages, getTerm } from 'shared/i18n/main' 8 | import { updateMenu as _updateMenu } from './update' 9 | import { setLanguage } from 'main/modules/language' 10 | import { getVirtualState, setVirtualState } from '../../state' 11 | import { DictionaryKeys } from 'shared/types' 12 | import { themes } from 'shared/themes/main' 13 | import { APP_CONFIG } from '~/app.config' 14 | import { IPC } from 'shared/constants' 15 | import { setTheme } from '../../theme' 16 | 17 | export const trayIconPath = join( 18 | __dirname, 19 | 'resources', 20 | 'icons', 21 | 'tray', 22 | 'trayTemplate.png' 23 | ) 24 | 25 | const shortcuts = userPreferences.get('shortcuts') 26 | 27 | export async function createContextMenu() { 28 | const { screen, mainWindow, tray, videoInputDevices, activeVideoInputId } = 29 | getVirtualState() 30 | 31 | const { WHEN_VIDEO_INPUT_CHANGES } = IPC.DEVICES 32 | 33 | function updateMenu(callback?: () => void) { 34 | setIfStoreShouldRestartWhenItChanges(false) 35 | 36 | callback && callback() 37 | 38 | _updateMenu() 39 | setTimeout(() => setIfStoreShouldRestartWhenItChanges(true), 1000) 40 | } 41 | 42 | const contextMenu = Menu.buildFromTemplate([ 43 | { 44 | label: `${APP_CONFIG.TITLE} v${APP_CONFIG.VERSION}`, 45 | icon: trayIconPath, 46 | enabled: false, 47 | }, 48 | 49 | { 50 | label: getTerm('settings'), 51 | accelerator: shortcuts.openPreferencesFile, 52 | click() { 53 | userPreferences.openInEditor() 54 | }, 55 | }, 56 | 57 | { 58 | label: getTerm('resetSettings'), 59 | async click() { 60 | const answer = await dialog.showMessageBox(mainWindow, { 61 | message: getTerm('resetSettingsConfirmation'), 62 | buttons: [getTerm('yes'), getTerm('no')], 63 | type: 'question', 64 | }) 65 | 66 | answer.response === 0 && userPreferences.set(getDefaultStore()) 67 | }, 68 | }, 69 | 70 | { 71 | type: 'separator', 72 | }, 73 | 74 | { 75 | label: getTerm('language'), 76 | submenu: languages.map((language) => { 77 | const currentLanguage = userPreferences.get('language') 78 | 79 | return { 80 | label: language.displayName, 81 | type: 'checkbox', 82 | enabled: language.id !== currentLanguage, 83 | checked: language.id === currentLanguage, 84 | click() { 85 | updateMenu(() => setLanguage(language.id)) 86 | }, 87 | } 88 | }), 89 | }, 90 | 91 | { 92 | label: getTerm('themes'), 93 | submenu: themes.map((theme) => { 94 | const currentTheme = userPreferences.get('theme') 95 | 96 | const label = 97 | theme.id === 'default' 98 | ? getTerm(theme.displayName.toLowerCase() as DictionaryKeys) 99 | : theme.displayName 100 | 101 | return { 102 | label, 103 | type: 'checkbox', 104 | enabled: theme.id !== currentTheme, 105 | checked: theme.id === currentTheme, 106 | click() { 107 | updateMenu(() => setTheme(theme.id)) 108 | }, 109 | } 110 | }), 111 | }, 112 | 113 | { 114 | type: 'separator', 115 | }, 116 | 117 | { 118 | type: 'submenu', 119 | label: getTerm('windowSize'), 120 | submenu: ['initial', 'large'].map((size: ScreenSize) => { 121 | const isCurrentSelectedSize = screen.getCurrentScreenSize() === size 122 | 123 | return { 124 | label: getTerm(size === 'initial' ? 'small' : 'large'), 125 | checked: isCurrentSelectedSize, 126 | enabled: !isCurrentSelectedSize, 127 | type: 'checkbox', 128 | click() { 129 | updateMenu(() => screen.setWindowSize(size)) 130 | }, 131 | } 132 | }), 133 | }, 134 | 135 | { 136 | type: 'submenu', 137 | label: getTerm('screenEdge'), 138 | submenu: ['topLeft', 'topRight', 'bottomRight', 'bottomLeft'].map( 139 | (edge) => { 140 | const normalizedEdgeKey = edge.replace( 141 | /[A-Z]/g, 142 | (letter) => `-${letter.toLocaleLowerCase()}` 143 | ) 144 | 145 | const isCurrentSelectedEdge = 146 | screen.getCurrentScreenEdge() === normalizedEdgeKey 147 | 148 | return { 149 | label: getTerm(edge as DictionaryKeys), 150 | checked: isCurrentSelectedEdge, 151 | enabled: !isCurrentSelectedEdge, 152 | type: 'checkbox', 153 | click() { 154 | updateMenu(() => { 155 | screen.moveWindowToScreenEdge(normalizedEdgeKey as ScreenEdge) 156 | }) 157 | }, 158 | } 159 | } 160 | ), 161 | }, 162 | 163 | { 164 | type: 'submenu', 165 | label: getTerm('displays'), 166 | submenu: screen.getAllDisplays().map((display) => { 167 | const displayNameOrId = display?.name || display.id 168 | const displayText = getTerm('display') 169 | 170 | const isCurrentSelectedDisplay = 171 | screen.getActiveDisplay() === display.id 172 | 173 | return { 174 | label: `${displayText} ${displayNameOrId} (${display.size.width}x${display.size.height})`, 175 | checked: isCurrentSelectedDisplay, 176 | enabled: !isCurrentSelectedDisplay, 177 | type: 'checkbox', 178 | click() { 179 | updateMenu(() => { 180 | screen.setActiveDisplay(display.id) 181 | screen.updateWindowPosition() 182 | }) 183 | }, 184 | } 185 | }), 186 | }, 187 | 188 | { 189 | type: 'submenu', 190 | label: getTerm('videoInputSource'), 191 | enabled: videoInputDevices?.length > 0, 192 | submenu: videoInputDevices 193 | ? videoInputDevices.map((device) => { 194 | const isCurentSelectedDevice = activeVideoInputId === device.id 195 | 196 | return { 197 | label: device.label, 198 | checked: isCurentSelectedDevice, 199 | enabled: !isCurentSelectedDevice, 200 | type: 'checkbox', 201 | click() { 202 | updateMenu(() => { 203 | setVirtualState({ activeVideoInputId: device.id }) 204 | 205 | mainWindow.webContents.send( 206 | WHEN_VIDEO_INPUT_CHANGES, 207 | device.id 208 | ) 209 | }) 210 | }, 211 | } 212 | }) 213 | : [], 214 | }, 215 | 216 | { 217 | type: 'separator', 218 | }, 219 | 220 | { 221 | type: 'normal', 222 | label: getTerm('close'), 223 | role: 'quit', 224 | enabled: true, 225 | }, 226 | ]) 227 | 228 | !tray.isDestroyed() && tray.setContextMenu(contextMenu) 229 | } 230 | -------------------------------------------------------------------------------- /src/main/modules/menu/actions/tray.ts: -------------------------------------------------------------------------------- 1 | import { Tray } from 'electron' 2 | 3 | import { createContextMenu, trayIconPath } from './context' 4 | import { setVirtualState } from '../../state' 5 | import { APP_CONFIG } from '~/app.config' 6 | 7 | export async function createTrayMenu() { 8 | const tray = new Tray(trayIconPath) 9 | 10 | setVirtualState({ tray }) 11 | 12 | tray.setToolTip(APP_CONFIG.TITLE) 13 | 14 | tray.on('click', () => tray.popUpContextMenu()) 15 | 16 | createContextMenu() 17 | } 18 | -------------------------------------------------------------------------------- /src/main/modules/menu/actions/update.ts: -------------------------------------------------------------------------------- 1 | import { getVirtualState } from 'main/modules/state' 2 | import { createContextMenu } from './context' 3 | import { IPC } from 'shared/constants' 4 | 5 | export async function updateMenu() { 6 | const { mainWindow } = getVirtualState() 7 | const { GET_VIDEO_INPUTS } = IPC.DEVICES 8 | 9 | await createContextMenu() 10 | 11 | mainWindow.webContents.send(GET_VIDEO_INPUTS) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/modules/menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions/context' 2 | export * from './actions/update' 3 | export * from './actions/tray' 4 | -------------------------------------------------------------------------------- /src/main/modules/screen/displays/adapters/mac.ts: -------------------------------------------------------------------------------- 1 | import { hexToDecimal } from 'shared/helpers' 2 | import { exec } from '~/bin/utils' 3 | 4 | let memoizedDisplays: any[] 5 | 6 | export function macOSDisplaysAdapter() { 7 | if (memoizedDisplays) { 8 | return memoizedDisplays 9 | } 10 | 11 | const [rawDisplaysData] = exec(['system_profiler -json SPDisplaysDataType']) 12 | const displaysData = JSON.parse(rawDisplaysData || '{}') 13 | 14 | memoizedDisplays = displaysData?.SPDisplaysDataType?.map( 15 | ({ spdisplays_ndrvs }: any) => 16 | spdisplays_ndrvs?.map( 17 | ({ _spdisplays_pixels, _spdisplays_displayID, _name }: any) => { 18 | const [width, height] = _spdisplays_pixels?.match(/\d+/g) || [0, 0] 19 | 20 | return { 21 | name: _name, 22 | id: hexToDecimal(_spdisplays_displayID), 23 | size: { 24 | width: Number(width), 25 | height: Number(height), 26 | }, 27 | } 28 | } 29 | ) 30 | ).flat(Infinity) 31 | 32 | return memoizedDisplays 33 | } 34 | -------------------------------------------------------------------------------- /src/main/modules/screen/displays/index.ts: -------------------------------------------------------------------------------- 1 | import { screen } from 'electron' 2 | 3 | import { macOSDisplaysAdapter } from './adapters/mac' 4 | 5 | export function getAllDisplays() { 6 | const displaysMapper = { 7 | darwin: macOSDisplaysAdapter, 8 | } 9 | 10 | const displaysByPlatform = displaysMapper[process.platform] 11 | const displaysByElectron = screen.getAllDisplays() 12 | 13 | if (displaysByPlatform) { 14 | return displaysByElectron.map((display) => { 15 | const displayByPlatform = displaysByPlatform().find( 16 | (displayByPlatform: any) => displayByPlatform.id === display.id 17 | ) 18 | 19 | if (displayByPlatform) { 20 | return { 21 | ...display, 22 | ...displayByPlatform, 23 | } 24 | } 25 | 26 | return display 27 | }) 28 | } 29 | 30 | return displaysByElectron 31 | } 32 | -------------------------------------------------------------------------------- /src/main/modules/screen/index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, screen as _screen, Rectangle } from 'electron' 2 | 3 | import { userPreferences } from 'shared/store' 4 | import { PLATFORM } from 'shared/constants' 5 | import { getAllDisplays } from './displays' 6 | import { Sizes } from 'shared/types' 7 | 8 | export interface ScreenProportions { 9 | initial: Sizes 10 | large: Sizes 11 | } 12 | 13 | export type ScreenMovement = 'left' | 'right' | 'top' | 'bottom' 14 | export type ScreenSize = 'initial' | 'large' 15 | 16 | export type ScreenEdge = 17 | | 'top-left' 18 | | 'top-right' 19 | | 'bottom-left' 20 | | 'bottom-right' 21 | 22 | export type ScreenEdgeMovements = Record< 23 | ScreenEdge, 24 | Partial> 25 | > 26 | 27 | export class ScreenModule { 28 | private window: BrowserWindow 29 | private currentScreenSize: ScreenSize 30 | private currentScreenEdge: ScreenEdge 31 | private currentDisplayId: number 32 | private screenSizes: ScreenProportions 33 | private isScreenVisible = true 34 | private currentX = 0 35 | private currentY = 0 36 | 37 | private windowPositionByScreenSize: Record< 38 | ScreenSize, 39 | { x: number; y: number } 40 | > 41 | 42 | constructor( 43 | window: BrowserWindow, 44 | initialScreenSize: ScreenSize = 'initial', 45 | initialScreenEdge: ScreenEdge = 'bottom-right' 46 | ) { 47 | this.window = window 48 | this.currentScreenEdge = initialScreenEdge 49 | this.currentScreenSize = initialScreenSize 50 | 51 | const currentWindowBounds = this.window.getBounds() 52 | 53 | this.currentDisplayId = _screen.getDisplayMatching(currentWindowBounds).id 54 | 55 | const { 56 | store: { screen }, 57 | } = userPreferences 58 | 59 | this.screenSizes = { 60 | initial: { 61 | width: screen.initial.width, 62 | height: screen.initial.height, 63 | }, 64 | 65 | large: { 66 | width: screen.large.width, 67 | height: screen.large.height, 68 | }, 69 | } 70 | 71 | const { x, y } = this.window.getBounds() 72 | 73 | this.windowPositionByScreenSize = { 74 | initial: { x, y }, 75 | large: { x, y }, 76 | } 77 | 78 | this.setCurrentWindowXY() 79 | } 80 | 81 | getAllDisplays() { 82 | return getAllDisplays() 83 | } 84 | 85 | getActiveDisplay() { 86 | return this.currentDisplayId 87 | } 88 | 89 | setActiveDisplay(displayId: number) { 90 | const display = this.getDisplayById(displayId) 91 | 92 | if (!display) return 93 | 94 | this.currentDisplayId = displayId 95 | } 96 | 97 | setActiveDisplayByWindowPosition() { 98 | const { detectedDisplayIdByWindowPosition } = 99 | this.getDisplayByWindowPosition() 100 | 101 | this.setActiveDisplay(detectedDisplayIdByWindowPosition) 102 | } 103 | 104 | getDisplayById(id: number): Electron.Display { 105 | const display = this.getAllDisplays().find((display) => display.id === id) 106 | 107 | return display 108 | } 109 | 110 | getDisplayByWindowPosition() { 111 | const activeDisplay = this.getActiveDisplay() 112 | const bounds = this.window.getBounds() 113 | 114 | const detectedDisplayIdByWindowPosition = 115 | _screen.getDisplayNearestPoint(bounds).id 116 | 117 | const isOnSameScreen = detectedDisplayIdByWindowPosition === activeDisplay 118 | 119 | return { detectedDisplayIdByWindowPosition, isOnSameScreen } 120 | } 121 | 122 | getScreenSizeInPixels() { 123 | const { width, height } = this.screenSizes[this.currentScreenSize] 124 | 125 | return { 126 | width, 127 | height, 128 | } 129 | } 130 | 131 | getCurrentScreenSize() { 132 | return this.currentScreenSize 133 | } 134 | 135 | getCurrentScreenEdge() { 136 | return this.currentScreenEdge 137 | } 138 | 139 | setWindowPositionByScreenSize() { 140 | this.windowPositionByScreenSize[this.currentScreenSize] = { 141 | x: this.currentX, 142 | y: this.currentY, 143 | } 144 | } 145 | 146 | setCurrentWindowXY() { 147 | this.currentX = this.window.getBounds().x 148 | this.currentY = this.window.getBounds().y 149 | } 150 | 151 | setWindowSize(size: ScreenSize) { 152 | if (this.currentScreenSize === size) { 153 | return 154 | } 155 | 156 | this.currentScreenSize = size 157 | this.memoLastWindowPosition() 158 | 159 | const { width, height } = this.getScreenSizeInPixels() 160 | const { x, y } = this.windowPositionByScreenSize[size] 161 | 162 | this.window.setMaximumSize(width, height) 163 | this.setWindowBounds({ width, height, x, y }) 164 | } 165 | 166 | setWindowBounds(bounds: Partial) { 167 | PLATFORM.IS_MAC 168 | ? this.window.setBounds(bounds, true) 169 | : this.window.setBounds(bounds) 170 | } 171 | 172 | updateWindowPosition() { 173 | const display = this.getDisplayById(this.currentDisplayId) 174 | 175 | this.window.setPosition(display.workArea.x, display.workArea.y) 176 | this.moveWindowToScreenEdge() 177 | } 178 | 179 | memoLastWindowPosition() { 180 | this.setWindowPositionByScreenSize() 181 | this.setCurrentWindowXY() 182 | } 183 | 184 | calculateScreenMovement(movement: ScreenMovement) { 185 | const edgeMovements: ScreenEdgeMovements = { 186 | 'top-right': { 187 | left: 'top-left', 188 | bottom: 'bottom-right', 189 | }, 190 | 191 | 'top-left': { 192 | right: 'top-right', 193 | bottom: 'bottom-left', 194 | }, 195 | 196 | 'bottom-right': { 197 | left: 'bottom-left', 198 | top: 'top-right', 199 | }, 200 | 201 | 'bottom-left': { 202 | right: 'bottom-right', 203 | top: 'top-left', 204 | }, 205 | } 206 | 207 | return ( 208 | edgeMovements[this.currentScreenEdge][movement] || this.currentScreenEdge 209 | ) 210 | } 211 | 212 | calculateWindowCenterPosition() { 213 | const { bounds } = this.getDisplayById(this.getActiveDisplay()) 214 | const { width, height } = this.getScreenSizeInPixels() 215 | 216 | const x = Math.round(bounds.x + (bounds.width - width) / 2) 217 | const y = Math.round(bounds.y + (bounds.height - height) / 2) 218 | 219 | return { x, y } 220 | } 221 | 222 | moveWindowToScreenEdge(edge = this.currentScreenEdge) { 223 | this.currentScreenEdge = edge 224 | 225 | const { x, y } = this.window.getBounds() 226 | const display = _screen.getDisplayNearestPoint({ x, y }) 227 | 228 | const bounds = { x: display.bounds.x, y: display.bounds.y } 229 | const { width, height } = this.getScreenSizeInPixels() 230 | 231 | const SCREEN_PADDING = 24 232 | 233 | switch (edge) { 234 | case 'top-left': 235 | bounds.x += SCREEN_PADDING 236 | bounds.y += SCREEN_PADDING 237 | break 238 | 239 | case 'bottom-left': 240 | bounds.x += SCREEN_PADDING 241 | bounds.y += display.size.height - height - SCREEN_PADDING 242 | break 243 | 244 | case 'top-right': 245 | bounds.x += display.size.width - width - SCREEN_PADDING 246 | bounds.y += SCREEN_PADDING 247 | break 248 | 249 | case 'bottom-right': 250 | bounds.x += display.size.width - width - SCREEN_PADDING 251 | bounds.y += display.size.height - height - SCREEN_PADDING 252 | break 253 | } 254 | 255 | this.setWindowBounds(bounds) 256 | } 257 | 258 | toggleWindowVisibility() { 259 | this.isScreenVisible ? this.window.hide() : this.window.show() 260 | this.isScreenVisible = !this.isScreenVisible 261 | } 262 | 263 | toggleWindowSize() { 264 | const size = this.currentScreenSize === 'initial' ? 'large' : 'initial' 265 | this.setWindowSize(size) 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/main/modules/shortcuts.ts: -------------------------------------------------------------------------------- 1 | import { globalShortcut } from 'electron' 2 | 3 | import { userPreferences } from 'shared/store' 4 | import { getVirtualState } from './state' 5 | import { updateMenu } from './menu' 6 | 7 | export function loadShortcutsModule() { 8 | const { screen } = getVirtualState() 9 | 10 | const { moveCamera, resizeCamera, hideCamera } = 11 | userPreferences.store.shortcuts 12 | 13 | screen.moveWindowToScreenEdge() 14 | 15 | globalShortcut.register(moveCamera.up, () => { 16 | screen.moveWindowToScreenEdge(screen.calculateScreenMovement('top')) 17 | updateMenu() 18 | }) 19 | 20 | globalShortcut.register(moveCamera.down, () => { 21 | screen.moveWindowToScreenEdge(screen.calculateScreenMovement('bottom')) 22 | updateMenu() 23 | }) 24 | 25 | globalShortcut.register(moveCamera.left, () => { 26 | screen.moveWindowToScreenEdge(screen.calculateScreenMovement('left')) 27 | updateMenu() 28 | }) 29 | 30 | globalShortcut.register(moveCamera.right, () => { 31 | screen.moveWindowToScreenEdge(screen.calculateScreenMovement('right')) 32 | updateMenu() 33 | }) 34 | 35 | globalShortcut.register(resizeCamera.toggle, () => { 36 | screen.toggleWindowSize() 37 | }) 38 | 39 | globalShortcut.register(hideCamera, () => { 40 | screen.toggleWindowVisibility() 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/main/modules/state/index.ts: -------------------------------------------------------------------------------- 1 | import { State, StateSlice } from './types' 2 | 3 | let state = { 4 | videoInputDevices: [], 5 | activeVideoInputId: '', 6 | } as State 7 | 8 | export function getVirtualState() { 9 | return state 10 | } 11 | 12 | export function setVirtualState(stateSlice: StateSlice) { 13 | state = { 14 | ...state, 15 | ...stateSlice, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/modules/state/types.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindowOrNull, VideoDevice } from 'shared/types' 2 | import { ScreenModule } from '../screen' 3 | 4 | export interface State { 5 | screen: ScreenModule 6 | mainWindow: BrowserWindowOrNull 7 | tray: Electron.Tray 8 | videoInputDevices: VideoDevice[] 9 | activeVideoInputId: string 10 | } 11 | 12 | export type StateSlice = Partial | Record 13 | -------------------------------------------------------------------------------- /src/main/modules/store/actions/merge.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge' 2 | 3 | import { userPreferences, getDefaultStore } from 'shared/store' 4 | 5 | export function mergePossibleNewDefaultsInStore() { 6 | const updatedPreferences = merge(getDefaultStore(), userPreferences.store) 7 | 8 | userPreferences.set(updatedPreferences) 9 | } 10 | -------------------------------------------------------------------------------- /src/main/modules/store/actions/watch.ts: -------------------------------------------------------------------------------- 1 | import chokidar from 'chokidar' 2 | import { app } from 'electron' 3 | 4 | import { userPreferences } from 'shared/store' 5 | 6 | let shouldRestartWhenStoreChanges = true 7 | 8 | export function setIfStoreShouldRestartWhenItChanges(state: boolean) { 9 | shouldRestartWhenStoreChanges = state 10 | } 11 | 12 | export function watchStoreFileAndRestartAppWhenItChanges() { 13 | chokidar.watch(userPreferences.path).on('change', () => { 14 | if (!shouldRestartWhenStoreChanges) return 15 | 16 | app.relaunch() 17 | app.exit() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/main/modules/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions/watch' 2 | export * from './actions/merge' 3 | -------------------------------------------------------------------------------- /src/main/modules/theme/actions/set.ts: -------------------------------------------------------------------------------- 1 | import { getVirtualState } from 'main/modules/state' 2 | import { userPreferences } from 'shared/store' 3 | import { IPC } from 'shared/constants' 4 | 5 | export function setTheme(id: string) { 6 | const { mainWindow } = getVirtualState() 7 | const { UPDATE } = IPC.THEMES 8 | 9 | userPreferences.set('theme', id) 10 | mainWindow.webContents.send(UPDATE, id) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/modules/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions/set' 2 | -------------------------------------------------------------------------------- /src/main/modules/window/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ipcs/size' 2 | -------------------------------------------------------------------------------- /src/main/modules/window/ipcs/size.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | 3 | import { getVirtualState } from 'main/modules/state' 4 | import { updateMenu } from 'main/modules/menu' 5 | import { IPC } from 'shared/constants' 6 | 7 | export function registerToggleWindowSizeByIPC() { 8 | const { screen } = getVirtualState() 9 | const { TOGGLE_SIZE } = IPC.WINDOWS 10 | 11 | ipcMain.on(TOGGLE_SIZE, () => { 12 | screen.toggleWindowSize() 13 | updateMenu() 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/main/windows/Main/index.ts: -------------------------------------------------------------------------------- 1 | import { nativeImage, app } from 'electron' 2 | import { join } from 'path' 3 | 4 | import { 5 | updateMenu, 6 | ScreenModule, 7 | createTrayMenu, 8 | setVirtualState, 9 | loadShortcutsModule, 10 | registerToggleWindowSizeByIPC, 11 | mergePossibleNewDefaultsInStore, 12 | watchStoreFileAndRestartAppWhenItChanges, 13 | } from 'main/modules' 14 | 15 | import { registerVideoInputListInTrayByIPC } from './ipcs' 16 | import { ENVIRONMENT, PLATFORM } from 'shared/constants' 17 | import { userPreferences } from 'shared/store' 18 | import { createWindow } from 'main/factories' 19 | import { APP_CONFIG } from '~/app.config' 20 | 21 | const { TITLE } = APP_CONFIG 22 | 23 | mergePossibleNewDefaultsInStore() 24 | 25 | export async function MainWindow() { 26 | const { 27 | store: { 28 | hasShadow, 29 | screen: { initial }, 30 | }, 31 | } = userPreferences 32 | 33 | const mainWindow = createWindow({ 34 | id: 'main', 35 | hasShadow, 36 | title: TITLE, 37 | frame: false, 38 | center: true, 39 | resizable: false, 40 | transparent: true, 41 | alwaysOnTop: true, 42 | maximizable: false, 43 | minimizable: false, 44 | width: initial.width, 45 | height: initial.height, 46 | maxWidth: initial.width, 47 | maxHeight: initial.height, 48 | titleBarStyle: 'customButtonsOnHover', 49 | 50 | icon: nativeImage.createFromPath( 51 | join(__dirname, 'resources', 'icons', 'icon.png') 52 | ), 53 | 54 | webPreferences: { 55 | spellcheck: false, 56 | nodeIntegration: false, 57 | contextIsolation: true, 58 | preload: join(__dirname, 'bridge.js'), 59 | }, 60 | }) 61 | 62 | const screenModule = new ScreenModule(mainWindow) 63 | 64 | setVirtualState({ screen: screenModule, mainWindow }) 65 | 66 | PLATFORM.IS_MAC && mainWindow.setWindowButtonVisibility(false) 67 | ENVIRONMENT.IS_DEV && mainWindow.webContents.openDevTools({ mode: 'detach' }) 68 | 69 | mainWindow.setAlwaysOnTop(true, 'screen-saver') 70 | 71 | mainWindow.setVisibleOnAllWorkspaces(true, { 72 | visibleOnFullScreen: true, 73 | }) 74 | 75 | PLATFORM.IS_MAC && app.dock.show() 76 | 77 | createTrayMenu() 78 | loadShortcutsModule() 79 | registerVideoInputListInTrayByIPC() 80 | registerToggleWindowSizeByIPC() 81 | watchStoreFileAndRestartAppWhenItChanges() 82 | 83 | mainWindow.on('moved', () => { 84 | const { isOnSameScreen } = screenModule.getDisplayByWindowPosition() 85 | 86 | if (isOnSameScreen) return 87 | 88 | screenModule.setActiveDisplayByWindowPosition() 89 | updateMenu() 90 | }) 91 | 92 | return mainWindow 93 | } 94 | -------------------------------------------------------------------------------- /src/main/windows/Main/ipcs/devices.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | 3 | import { createContextMenu, setVirtualState, updateMenu } from 'main/modules' 4 | import { VideoDevice } from 'shared/types' 5 | import { IPC } from 'shared/constants' 6 | 7 | export function registerVideoInputListInTrayByIPC() { 8 | const { GET_VIDEO_INPUTS, GET_ACTIVE_VIDEO_INPUT_ID } = IPC.DEVICES 9 | 10 | ipcMain.on(GET_VIDEO_INPUTS, (_, devices: VideoDevice[]) => { 11 | if (devices.length < 1) return 12 | 13 | setVirtualState({ 14 | videoInputDevices: devices, 15 | }) 16 | 17 | createContextMenu() 18 | }) 19 | 20 | ipcMain.on(GET_ACTIVE_VIDEO_INPUT_ID, (_, id: string) => { 21 | if (!id) return 22 | 23 | setVirtualState({ 24 | activeVideoInputId: id, 25 | }) 26 | 27 | updateMenu() 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/main/windows/Main/ipcs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './devices' 2 | -------------------------------------------------------------------------------- /src/main/windows/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Main' 2 | -------------------------------------------------------------------------------- /src/renderer/bridge/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron' 2 | 3 | import { userPreferences } from 'shared/store' 4 | import * as ipcs from './ipcs' 5 | 6 | declare global { 7 | interface Window { 8 | MiniVideoMe: typeof API 9 | } 10 | } 11 | 12 | export const API = { 13 | ...ipcs, 14 | config: userPreferences.store, 15 | openPreferencesFile: () => userPreferences.openInEditor(), 16 | } 17 | 18 | contextBridge.exposeInMainWorld('MiniVideoMe', API) 19 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/devices/send.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | import { VideoDevice } from 'shared/types' 4 | import { IPC } from 'shared/constants' 5 | 6 | export function sendVideoInputDeviceList(devices: VideoDevice[]) { 7 | const { GET_VIDEO_INPUTS } = IPC.DEVICES 8 | 9 | ipcRenderer.send(GET_VIDEO_INPUTS, devices) 10 | } 11 | 12 | export function sendActiveVideoInputId(id: string) { 13 | const { GET_ACTIVE_VIDEO_INPUT_ID } = IPC.DEVICES 14 | 15 | ipcRenderer.send(GET_ACTIVE_VIDEO_INPUT_ID, id) 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/devices/when/request-video-input-list.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | import { IPC } from 'shared/constants' 4 | 5 | export function whenRequestVideoInputList(fn: (...args: any[]) => void) { 6 | const { GET_VIDEO_INPUTS } = IPC.DEVICES 7 | 8 | ipcRenderer.on(GET_VIDEO_INPUTS, (_, ...args) => { 9 | fn(...args) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/devices/when/video-input-changes.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | import { IPC } from 'shared/constants' 4 | 5 | export function whenVideoInputChanges(fn: (...args: any[]) => void) { 6 | const { WHEN_VIDEO_INPUT_CHANGES } = IPC.DEVICES 7 | 8 | ipcRenderer.on(WHEN_VIDEO_INPUT_CHANGES, (_, ...args) => { 9 | fn(...args) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './devices/when/request-video-input-list' 2 | export * from './devices/when/video-input-changes' 3 | export * from './devices/send' 4 | 5 | export * from './languages' 6 | export * from './window' 7 | export * from './themes' 8 | export * from './on' 9 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/languages.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | import { IPC } from 'shared/constants' 4 | 5 | export function whenLanguageUpdates(fn: (...args: any[]) => void) { 6 | const { UPDATE } = IPC.LANGUAGES 7 | 8 | ipcRenderer.on(UPDATE, (_, ...args) => { 9 | fn(...args) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/on.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | export function on(channel: string, callback: Function) { 4 | ipcRenderer.on(channel, (_, data) => callback(data)) 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/themes.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | import { IPC } from 'shared/constants' 4 | 5 | export function whenThemeUpdates(fn: (...args: any[]) => void) { 6 | const { UPDATE } = IPC.THEMES 7 | 8 | ipcRenderer.on(UPDATE, (_, ...args) => { 9 | fn(...args) 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/bridge/ipcs/window.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | import { IPC } from 'shared/constants' 4 | 5 | export function toggleWindowSize() { 6 | const { TOGGLE_SIZE } = IPC.WINDOWS 7 | 8 | ipcRenderer.send(TOGGLE_SIZE) 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/components/Camera/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCamera } from 'renderer/hooks' 2 | import { Status } from '../Status' 3 | 4 | export function Camera() { 5 | const { status, isCameraFound, videoElementRef, wrapperElementRef } = 6 | useCamera() 7 | 8 | if (!isCameraFound) { 9 | return 10 | } 11 | 12 | return ( 13 |
14 |
15 | 16 |
17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/components/Status/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLanguage } from 'renderer/hooks' 2 | 3 | export function Status({ status }) { 4 | const { getTerm } = useLanguage() 5 | 6 | return ( 7 |
8 |

{getTerm(status)}...

9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Camera' 2 | export * from './Status' 3 | -------------------------------------------------------------------------------- /src/renderer/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useLookForVideoInputDevices' 2 | export * from './useMouseAutoHide' 3 | export * from './useLanguage' 4 | export * from './useCamera' 5 | export * from './useShapes' 6 | export * from './useTheme' 7 | export * from './useRoot' 8 | -------------------------------------------------------------------------------- /src/renderer/hooks/useCamera.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 2 | 3 | import { useLookForVideoInputDevices } from './useLookForVideoInputDevices' 4 | import { useMouseAutoHide } from './useMouseAutoHide' 5 | import { useShortcuts } from './useShortcuts' 6 | import { config } from 'renderer/store' 7 | import { useShapes } from './useShapes' 8 | 9 | type Statuses = 'loading' | 'notFound' | 'connected' | 'disconnected' 10 | type MovePosition = 'left' | 'right' | 'up' | 'down' 11 | type ZoomType = 'in' | 'out' 12 | 13 | const { MiniVideoMe } = window 14 | const { shortcuts } = config 15 | 16 | let isFirstLoad = false 17 | let camIndex = 0 18 | 19 | export function useCamera() { 20 | const [isFlipped, setIsFlipped] = useState(config.flipHorizontal) 21 | const [status, setStatus] = useState('loading') 22 | const [deviceId, setDeviceId] = useState(null) 23 | const { toggleShapes } = useShapes() 24 | 25 | const [position, setPosition] = useState({ 26 | x: config.horizontal, 27 | y: config.vertical, 28 | z: config.scale, 29 | }) 30 | 31 | const videoInputDevicesRef = useRef([]) 32 | const wrapperElementRef = useRef(null) 33 | const videoElementRef = useRef(null) 34 | 35 | const shortcutActionsMapper = useMemo( 36 | () => ({ 37 | [shortcuts.adjustCameraOffset.left]: () => adjustOffset('left'), 38 | [shortcuts.adjustCameraOffset.right]: () => adjustOffset('right'), 39 | [shortcuts.adjustCameraOffset.up]: () => adjustOffset('up'), 40 | [shortcuts.adjustCameraOffset.down]: () => adjustOffset('down'), 41 | [shortcuts.toggleCam]: () => toggleCam(), 42 | [shortcuts.toggleWindowSize]: () => toggleWindowSize(), 43 | [shortcuts.toggleShapes]: () => toggleShapes(), 44 | [shortcuts.reset]: () => reset(), 45 | [shortcuts.zoom.in]: () => zoom('in'), 46 | [shortcuts.zoom.out]: () => zoom('out'), 47 | [shortcuts.flipHorizontal]: () => flipHorizontal(), 48 | [shortcuts.openPreferencesFile]: () => MiniVideoMe.openPreferencesFile(), 49 | }), 50 | [] 51 | ) 52 | 53 | useMouseAutoHide() 54 | useShortcuts(shortcutActionsMapper) 55 | 56 | const initCam = useCallback(() => { 57 | getVideoDeviceList().then((devices) => { 58 | videoInputDevicesRef.current = devices 59 | 60 | const constraints: MediaStreamConstraints = { 61 | video: 62 | { 63 | ...config, 64 | deviceId, 65 | frameRate: { ideal: config.frameRate }, 66 | } || true, 67 | } 68 | 69 | navigator.mediaDevices 70 | .getUserMedia(constraints) 71 | .then((stream) => { 72 | videoElementRef.current.srcObject = stream 73 | 74 | setStatus('connected') 75 | }) 76 | .catch(() => { 77 | setShouldLookForDevices(true) 78 | setDeviceId(null) 79 | 80 | setTimeout(() => { 81 | if (isLookingForDevices) { 82 | setStatus('notFound') 83 | } 84 | }, 3000) 85 | }) 86 | 87 | const availableDevices = videoInputDevicesRef.current.map((device) => { 88 | return { 89 | id: device.deviceId, 90 | label: device.label, 91 | } 92 | }) 93 | 94 | MiniVideoMe.sendActiveVideoInputId(deviceId) 95 | MiniVideoMe.sendVideoInputDeviceList(availableDevices) 96 | 97 | applyDefaultStyles() 98 | }) 99 | }, [deviceId]) 100 | 101 | const getVideoDeviceList = useCallback(async () => { 102 | const devices = await navigator.mediaDevices.enumerateDevices() 103 | 104 | const videoDevices = devices.filter( 105 | (device) => device.kind === 'videoinput' 106 | ) 107 | 108 | return videoDevices 109 | }, []) 110 | 111 | const toggleCam = useCallback(() => { 112 | getVideoDeviceList().then((videoDevices) => { 113 | const maxCamIndex = videoDevices.length - 1 114 | 115 | camIndex = camIndex === maxCamIndex ? 0 : camIndex + 1 116 | 117 | const deviceId = videoDevices[camIndex].deviceId 118 | 119 | MiniVideoMe.sendActiveVideoInputId(deviceId) 120 | 121 | navigator.mediaDevices 122 | .getUserMedia({ 123 | video: 124 | { 125 | ...config, 126 | deviceId, 127 | } || true, 128 | }) 129 | .then((stream) => { 130 | videoElementRef.current.srcObject = stream 131 | }) 132 | }) 133 | }, [camIndex]) 134 | 135 | const getDevice = useCallback(async (deviceId: string) => { 136 | return navigator.mediaDevices 137 | .getUserMedia({ video: { ...config, deviceId } || true }) 138 | .then((stream) => (videoElementRef.current.srcObject = stream)) 139 | }, []) 140 | 141 | const applyDefaultStyles = useCallback(() => { 142 | applyPositioning() 143 | applyBorder() 144 | applyShape() 145 | 146 | if (!isFirstLoad) { 147 | flipHorizontal() 148 | isFirstLoad = true 149 | } 150 | }, [isFirstLoad]) 151 | 152 | const changeWrapperSize = useCallback(() => { 153 | MiniVideoMe.toggleWindowSize() 154 | }, []) 155 | 156 | const flipHorizontal = useCallback(() => { 157 | setIsFlipped((isFlipped) => !isFlipped) 158 | }, []) 159 | 160 | const applyPositioning = useCallback(() => { 161 | if (!videoElementRef.current) return 162 | 163 | videoElementRef.current.style.transform = `translate(${position.x}%, ${position.y}%) scale(${position.z})` 164 | 165 | if (isFlipped) { 166 | videoElementRef.current.style.transform += 'rotateY(180deg)' 167 | } 168 | }, [position.x, position.y, position.z, isFlipped]) 169 | 170 | const adjustOffset = useCallback((position: MovePosition) => { 171 | switch (position) { 172 | case 'up': 173 | setPosition((position) => ({ 174 | ...position, 175 | y: (position.y -= 1), 176 | })) 177 | break 178 | 179 | case 'down': 180 | setPosition((position) => ({ 181 | ...position, 182 | y: (position.y += 1), 183 | })) 184 | break 185 | 186 | case 'left': 187 | setPosition((position) => ({ 188 | ...position, 189 | x: (position.x -= 1), 190 | })) 191 | break 192 | 193 | case 'right': 194 | setPosition((position) => ({ 195 | ...position, 196 | x: (position.x += 1), 197 | })) 198 | break 199 | } 200 | }, []) 201 | 202 | const zoom = useCallback((type: ZoomType) => { 203 | if (type === 'in') { 204 | setPosition((position) => ({ 205 | ...position, 206 | z: (position.z += 0.1), 207 | })) 208 | 209 | return 210 | } 211 | 212 | setPosition((position) => ({ 213 | ...position, 214 | z: (position.z -= 0.1), 215 | })) 216 | }, []) 217 | 218 | const toggleWindowSize = useCallback(() => { 219 | MiniVideoMe.toggleWindowSize() 220 | }, []) 221 | 222 | const reset = useCallback(() => { 223 | setPosition((position) => ({ 224 | ...position, 225 | z: 1, 226 | })) 227 | }, []) 228 | 229 | const applyShape = useCallback(() => { 230 | if (!wrapperElementRef.current) return 231 | 232 | wrapperElementRef.current.classList.add('has-clip-path') 233 | }, []) 234 | 235 | const applyBorder = useCallback(() => { 236 | if (!wrapperElementRef.current) return 237 | 238 | wrapperElementRef.current.classList.add('has-border') 239 | }, []) 240 | 241 | const { setShouldLookForDevices, isLookingForDevices } = 242 | useLookForVideoInputDevices({ 243 | deviceId, 244 | setDeviceId, 245 | videoInputDevicesRef, 246 | callback: initCam, 247 | }) 248 | 249 | useEffect(() => { 250 | initCam() 251 | }, [deviceId]) 252 | 253 | useEffect(() => { 254 | navigator.mediaDevices.ondevicechange = () => { 255 | getDevice(deviceId).catch((error) => { 256 | if (error.name === 'NotFoundError') { 257 | setDeviceId(null) 258 | setShouldLookForDevices(true) 259 | setStatus('disconnected') 260 | } 261 | }) 262 | } 263 | 264 | MiniVideoMe.whenVideoInputChanges(async (deviceId: string) => { 265 | getDevice(deviceId) 266 | 267 | const devices = await getVideoDeviceList() 268 | const index = devices.findIndex((device) => device.deviceId === deviceId) 269 | 270 | if (index !== -1) { 271 | camIndex = index 272 | return 273 | } 274 | 275 | camIndex = 0 276 | }) 277 | 278 | MiniVideoMe.whenRequestVideoInputList(() => { 279 | const availableDevices = videoInputDevicesRef.current.map((device) => { 280 | return { 281 | id: device.deviceId, 282 | label: device.label, 283 | } 284 | }) 285 | 286 | MiniVideoMe.sendActiveVideoInputId(deviceId) 287 | MiniVideoMe.sendVideoInputDeviceList(availableDevices) 288 | }) 289 | 290 | window.ondblclick = changeWrapperSize 291 | }, []) 292 | 293 | useEffect(() => { 294 | applyPositioning() 295 | }, [isFlipped, position.x, position.y, position.z]) 296 | 297 | return { 298 | status, 299 | toggleShapes, 300 | videoElementRef, 301 | wrapperElementRef, 302 | applyStyles: applyDefaultStyles, 303 | isCameraFound: Boolean(deviceId), 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/renderer/hooks/useLanguage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useMemo, 3 | useState, 4 | useEffect, 5 | useContext, 6 | useCallback, 7 | createContext, 8 | } from 'react' 9 | 10 | import { 11 | languages, 12 | defaultLanguage, 13 | getTerm as _getTerm, 14 | } from 'shared/i18n/renderer' 15 | 16 | import { DictionaryKeys, Language } from 'shared/types' 17 | import { config } from 'renderer/store' 18 | 19 | interface LanguageStore extends Language { 20 | getTerm: (term: DictionaryKeys) => string 21 | } 22 | 23 | const { MiniVideoMe } = window 24 | const LanguageContext = createContext({} as LanguageStore) 25 | 26 | export function useLanguage() { 27 | return useContext(LanguageContext) 28 | } 29 | 30 | export function LanguageProvider({ children }) { 31 | const [currentLanguageId, setCurrentLanguageId] = useState(config.language) 32 | 33 | const language = useMemo( 34 | () => 35 | languages.find((language) => language.id === currentLanguageId) || 36 | defaultLanguage, 37 | [currentLanguageId] 38 | ) 39 | 40 | const getTerm = useCallback( 41 | (key: DictionaryKeys, languageId?: string) => { 42 | return _getTerm(key, languageId || currentLanguageId) 43 | }, 44 | [currentLanguageId] 45 | ) 46 | 47 | useEffect(() => { 48 | MiniVideoMe.whenLanguageUpdates((languageId) => { 49 | setCurrentLanguageId(languageId) 50 | }) 51 | }, []) 52 | 53 | return ( 54 | 60 | {children} 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/renderer/hooks/useLookForVideoInputDevices.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | let isLookingForDevices: NodeJS.Timeout 4 | 5 | export function useLookForVideoInputDevices({ 6 | callback, 7 | deviceId, 8 | setDeviceId, 9 | videoInputDevicesRef, 10 | }) { 11 | const [shouldLookForDevices, setShouldLookForDevices] = useState(false) 12 | 13 | useEffect(() => { 14 | if (deviceId || !shouldLookForDevices) return 15 | 16 | isLookingForDevices = setInterval(() => { 17 | const deviceId = videoInputDevicesRef.current[0]?.deviceId 18 | 19 | callback() 20 | 21 | if (deviceId) { 22 | clearInterval(isLookingForDevices) 23 | setShouldLookForDevices(false) 24 | } 25 | 26 | setDeviceId(videoInputDevicesRef.current[0]?.deviceId) 27 | }, 1000) 28 | 29 | return () => clearInterval(isLookingForDevices) 30 | }, [shouldLookForDevices]) 31 | 32 | return { 33 | isLookingForDevices, 34 | shouldLookForDevices, 35 | setShouldLookForDevices, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/hooks/useMouseAutoHide.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | let mouseHideTimeout: NodeJS.Timeout 4 | 5 | function hideMouse() { 6 | clearTimeout(mouseHideTimeout) 7 | 8 | document.body.style.cursor = 'default' 9 | 10 | mouseHideTimeout = setTimeout(() => { 11 | document.body.style.cursor = 'none' 12 | }, 1000) 13 | } 14 | 15 | export function useMouseAutoHide() { 16 | useEffect(() => { 17 | window.addEventListener('mousemove', hideMouse) 18 | 19 | return () => window.removeEventListener('mousemove', hideMouse) 20 | }, []) 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/hooks/useRoot.ts: -------------------------------------------------------------------------------- 1 | const root = document.querySelector(':root') as HTMLDivElement 2 | 3 | export function useRoot() { 4 | return root 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/hooks/useShapes.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | 3 | import { config } from 'renderer/store' 4 | import { useRoot } from './useRoot' 5 | 6 | let currentShapePosition = 0 7 | 8 | export function useShapes() { 9 | const root = useRoot() 10 | 11 | const getShape = useCallback( 12 | () => config.shapes[currentShapePosition] || config.shapes[0], 13 | [] 14 | ) 15 | 16 | const applyShape = useCallback( 17 | () => root.style.setProperty('--clip-path', getShape()), 18 | [] 19 | ) 20 | 21 | const toggleShapes = useCallback(() => { 22 | const shapePosition = currentShapePosition + 1 23 | 24 | if (config.shapes.length - 1 < shapePosition) { 25 | currentShapePosition = 0 26 | } else { 27 | currentShapePosition = shapePosition 28 | } 29 | 30 | applyShape() 31 | }, []) 32 | 33 | useEffect(() => { 34 | applyShape() 35 | }, []) 36 | 37 | return { 38 | toggleShapes, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/hooks/useShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo } from 'react' 2 | 3 | import { normalizeShortcutMapperKeys } from 'shared/helpers' 4 | 5 | interface ShortcutActionsMapper { 6 | [key: string]: () => void 7 | } 8 | 9 | export function useShortcuts(shortcutActionsMapper: ShortcutActionsMapper) { 10 | const normalizedShortcutActionsMapper = useMemo( 11 | () => normalizeShortcutMapperKeys(shortcutActionsMapper), 12 | [] 13 | ) 14 | 15 | const isValidShortcut = useCallback( 16 | (key: string) => { 17 | return key in normalizedShortcutActionsMapper 18 | }, 19 | [normalizedShortcutActionsMapper] 20 | ) 21 | 22 | useEffect(() => { 23 | window.addEventListener('keydown', (event) => { 24 | const keys = new Set() 25 | const isEmptyKey = !event.key.replace(/\s+/g, '') 26 | 27 | event.metaKey && keys.add('meta') 28 | event.altKey && keys.add('alt') 29 | event.ctrlKey && keys.add('control') 30 | event.shiftKey && keys.add('shift') 31 | 32 | !isEmptyKey && event.key && keys.add(event.key.toLowerCase()) 33 | isEmptyKey && event.code && keys.add(event.code.toLowerCase()) 34 | 35 | const shortcut = [...keys].join('+') 36 | 37 | if (isValidShortcut(shortcut)) { 38 | normalizedShortcutActionsMapper[shortcut]() 39 | } 40 | }) 41 | }, []) 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react' 2 | 3 | import { defaultTheme, themes } from 'shared/themes/renderer' 4 | import { config } from 'renderer/store' 5 | import { useRoot } from './useRoot' 6 | 7 | const { MiniVideoMe } = window 8 | 9 | export function useTheme() { 10 | const [currentThemeId, setCurrentThemeId] = useState(config.theme) 11 | const root = useRoot() 12 | 13 | const theme = useMemo(() => { 14 | const theme = 15 | themes.find((theme) => theme.id === currentThemeId) || defaultTheme 16 | 17 | const entriesToOverrideInTheme = Object.entries(config.themeOverrides) 18 | 19 | const themeWithPossibleOverrides = entriesToOverrideInTheme.reduce( 20 | (acc, [key, value]) => ({ ...acc, [key]: value ? value : theme[key] }), 21 | theme 22 | ) 23 | 24 | return themeWithPossibleOverrides 25 | }, [currentThemeId]) 26 | 27 | const applyTheme = useCallback(() => { 28 | applyBorder() 29 | applyTextColor() 30 | }, [theme]) 31 | 32 | const applyBorder = useCallback(() => { 33 | theme?.borderColor && 34 | root.style.setProperty('--border-color', theme.borderColor) 35 | 36 | theme?.borderWidth && 37 | root.style.setProperty('--border-width', theme.borderWidth) 38 | }, [theme]) 39 | 40 | const applyTextColor = useCallback(() => { 41 | theme?.textColor && root.style.setProperty('--text-color', theme.textColor) 42 | }, [theme]) 43 | 44 | useEffect(() => { 45 | MiniVideoMe.whenThemeUpdates((themeId) => { 46 | setCurrentThemeId(themeId) 47 | }) 48 | }, []) 49 | 50 | useEffect(() => { 51 | applyTheme() 52 | }, [currentThemeId]) 53 | 54 | return { 55 | applyTheme, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDom from 'react-dom' 2 | import React from 'react' 3 | 4 | import { LanguageProvider } from './hooks' 5 | import { AppRoutes } from './routes' 6 | 7 | import './styles/globals.css' 8 | 9 | ReactDom.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.querySelector('app') 16 | ) 17 | -------------------------------------------------------------------------------- /src/renderer/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { WindowRouter, Route } from './modules' 2 | 3 | import { MainScreen } from 'renderer/screens' 4 | 5 | export function AppRoutes() { 6 | return ( 7 | } />, 10 | }} 11 | /> 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/routes/modules/index.tsx: -------------------------------------------------------------------------------- 1 | import { HashRouter as Router, Routes } from 'react-router-dom' 2 | 3 | export { Route } from 'react-router-dom' 4 | 5 | interface WindowRouter { 6 | routes: { 7 | [windowID: string]: () => JSX.Element 8 | } 9 | } 10 | 11 | export function WindowRouter({ routes }: WindowRouter) { 12 | const selectAllSlashes = /\//g 13 | const windowID = location.hash.split(selectAllSlashes)?.[1] || 'main' 14 | const lowerCasedWindowID = windowID.toLowerCase() 15 | 16 | const routesWithLowerCasedKeys = Object.keys(routes).reduce( 17 | (acc, key) => ({ 18 | ...acc, 19 | [key.toLowerCase()]: routes[key], 20 | }), 21 | {} 22 | ) 23 | 24 | const Route = routesWithLowerCasedKeys[lowerCasedWindowID] 25 | 26 | if (!Route) return null 27 | 28 | return ( 29 | 30 | {Route()} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/screens/Main/index.tsx: -------------------------------------------------------------------------------- 1 | import { Camera } from 'renderer/components' 2 | import { useTheme } from 'renderer/hooks' 3 | 4 | export function MainScreen() { 5 | useTheme() 6 | 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/screens/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Main' 2 | -------------------------------------------------------------------------------- /src/renderer/store/config.ts: -------------------------------------------------------------------------------- 1 | const userPreferences = window.MiniVideoMe.config 2 | 3 | const { 4 | zoom, 5 | theme, 6 | camera, 7 | anchor, 8 | shapes, 9 | screen, 10 | language, 11 | shortcuts, 12 | customThemes, 13 | flipHorizontal, 14 | customLanguages, 15 | themeOverrides, 16 | } = userPreferences 17 | 18 | export const config = { 19 | theme: theme || 'default', 20 | customThemes: customThemes || [], 21 | themeOverrides: themeOverrides || {}, 22 | language: language || 'default', 23 | customLanguages: customLanguages || [], 24 | width: Number(camera.width), 25 | height: Number(camera.height), 26 | frameRate: Number(camera.frameRate), 27 | flipHorizontal: Boolean(flipHorizontal), 28 | scale: Number(zoom ?? 1), 29 | horizontal: Number(anchor.x ?? 0), 30 | vertical: Number(anchor.y ?? 0), 31 | shapes: shapes, 32 | screenInitialWidth: screen.initial.width, 33 | screenInitialHeight: screen.initial.height, 34 | screenLargeWidth: screen.large.width, 35 | screenLargeHeight: screen.large.height, 36 | 37 | shortcuts: { 38 | openPreferencesFile: shortcuts.openPreferencesFile, 39 | adjustCameraOffset: shortcuts.adjustCameraOffset, 40 | flipHorizontal: shortcuts.flipHorizontal, 41 | toggleCam: shortcuts.toggleCam, 42 | toggleShapes: shortcuts.toggleShapes, 43 | toggleWindowSize: shortcuts.toggleWindowSize, 44 | reset: shortcuts.reset, 45 | zoom: shortcuts.zoom, 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | -------------------------------------------------------------------------------- /src/renderer/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import './resets.css'; 2 | 3 | :root { 4 | --text-color: #fff; 5 | --border-color: linear-gradient(to right, #988bc7, #ff79c6); 6 | --clip-path: circle(50% at 50% 50%); 7 | --border-width: 5px; 8 | } 9 | 10 | button { 11 | -webkit-app-region: nodrag; 12 | } 13 | 14 | body { 15 | overflow: hidden; 16 | } 17 | 18 | body, 19 | app, 20 | #video-grid { 21 | -webkit-app-region: drag; 22 | } 23 | 24 | video { 25 | height: 100vh; 26 | pointer-events: none; 27 | background: #121214; 28 | } 29 | 30 | .flip { 31 | transform: rotateY(180deg); 32 | } 33 | 34 | #wrapper { 35 | width: 100vw; 36 | height: 100vh; 37 | display: flex; 38 | justify-content: center; 39 | 40 | margin: auto; 41 | 42 | position: relative; 43 | box-sizing: border-box; 44 | 45 | color: var(--text-color); 46 | background: transparent; 47 | background-clip: padding-box; 48 | } 49 | 50 | #wrapper:before { 51 | content: ''; 52 | position: absolute; 53 | top: 0; 54 | right: 0; 55 | bottom: 0; 56 | left: 0; 57 | z-index: -1; 58 | margin: calc(-1 * var(--border-width)); 59 | } 60 | 61 | .video-wrapper { 62 | overflow: hidden; 63 | display: flex; 64 | align-items: center; 65 | justify-content: center; 66 | background: transparent; 67 | } 68 | 69 | #wrapper.has-border { 70 | border: solid transparent; 71 | border-width: var(--border-width); 72 | } 73 | 74 | #wrapper.has-border:before { 75 | background: var(--border-color); 76 | } 77 | 78 | #wrapper.has-clip-path:before, 79 | #wrapper.has-clip-path .video-wrapper { 80 | clip-path: var(--clip-path); 81 | } 82 | 83 | .message-wrapper { 84 | display: flex; 85 | justify-content: center; 86 | align-items: center; 87 | width: 100vw; 88 | height: 100vh; 89 | background: var(--border-color); 90 | color: var(--text-color); 91 | clip-path: var(--clip-path); 92 | position: relative; 93 | } 94 | 95 | -------------------------------------------------------------------------------- /src/renderer/styles/resets.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | outline: 0; 5 | border: 0; 6 | list-style: none; 7 | text-decoration: none; 8 | box-sizing: border-box; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 10 | } 11 | -------------------------------------------------------------------------------- /src/resources/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/src/resources/icons/icon.icns -------------------------------------------------------------------------------- /src/resources/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/src/resources/icons/icon.ico -------------------------------------------------------------------------------- /src/resources/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/src/resources/icons/icon.png -------------------------------------------------------------------------------- /src/resources/icons/tray/trayTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/src/resources/icons/tray/trayTemplate.png -------------------------------------------------------------------------------- /src/resources/icons/tray/trayTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maykbrito/mini-video-me/e0ef58d8003a80ad4c1920a69f8a5453b6c70a2d/src/resources/icons/tray/trayTemplate@2x.png -------------------------------------------------------------------------------- /src/shared/constants/environment.ts: -------------------------------------------------------------------------------- 1 | export const ENVIRONMENT = { 2 | IS_DEV: process.env.NODE_ENV === 'development', 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './environment' 2 | export * from './platform' 3 | export * from './ipc' 4 | -------------------------------------------------------------------------------- /src/shared/constants/ipc.ts: -------------------------------------------------------------------------------- 1 | export const IPC = { 2 | WINDOWS: { 3 | TOGGLE_SIZE: 'windows: toggle-size', 4 | }, 5 | 6 | DEVICES: { 7 | GET_VIDEO_INPUTS: 'devices: get-video-inputs', 8 | GET_ACTIVE_VIDEO_INPUT_ID: 'devices: get-active-video-input-id', 9 | WHEN_VIDEO_INPUT_CHANGES: 'devices: when-video-inputs-changes', 10 | }, 11 | 12 | THEMES: { 13 | UPDATE: 'themes: update', 14 | }, 15 | 16 | LANGUAGES: { 17 | UPDATE: 'languages: update', 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /src/shared/constants/platform.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM = { 2 | IS_MAC: process.platform === 'darwin', 3 | IS_WINDOWS: process.platform === 'win32', 4 | IS_LINUX: process.platform === 'linux', 5 | } 6 | -------------------------------------------------------------------------------- /src/shared/helpers/checkers.ts: -------------------------------------------------------------------------------- 1 | export function isString(it: any): it is string { 2 | return typeof it === 'string' 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/helpers/converters.ts: -------------------------------------------------------------------------------- 1 | export function hexToDecimal(hex: string) { 2 | const _hex = parseInt(hex, 16) 3 | 4 | return Number(_hex.toString(10)) 5 | } 6 | 7 | export function decimalToHex(decimal: string) { 8 | const _decimal = parseInt(decimal, 10) 9 | 10 | return _decimal.toString(16) 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './converters' 2 | export * from './keyboard' 3 | export * from './checkers' 4 | -------------------------------------------------------------------------------- /src/shared/helpers/keyboard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './normalize' 2 | -------------------------------------------------------------------------------- /src/shared/helpers/keyboard/normalize.ts: -------------------------------------------------------------------------------- 1 | import { PLATFORM } from 'shared/constants' 2 | 3 | export function normalizeShortcutKey(key: string) { 4 | let normalizedKey = key.toLowerCase() 5 | 6 | normalizedKey.includes('commandorcontrol') && 7 | (normalizedKey = normalizedKey.replace( 8 | 'commandorcontrol', 9 | PLATFORM.IS_MAC ? 'meta' : 'control' 10 | )) 11 | 12 | normalizedKey.includes('optionoralt') && 13 | (normalizedKey = normalizedKey.replace( 14 | 'optionoralt', 15 | PLATFORM.IS_MAC ? 'option' : 'alt' 16 | )) 17 | 18 | return normalizedKey 19 | } 20 | 21 | export function normalizeShortcutMapperKeys(mapper: Record) { 22 | const transformedMapper = Object.entries(mapper).reduce( 23 | (acc, [key, value]) => ({ 24 | ...acc, 25 | [normalizeShortcutKey(key)]: value, 26 | }), 27 | {} 28 | ) 29 | 30 | return transformedMapper 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/i18n/factory.ts: -------------------------------------------------------------------------------- 1 | import { DictionaryKeys, Language } from 'shared/types' 2 | import * as languagePack from 'i18n' 3 | 4 | export function makeLanguage( 5 | initialLanguageId: () => string, 6 | customLanguages: () => Language[] 7 | ) { 8 | const mainLanguages: Language[] = Object.values(languagePack) 9 | 10 | const defaultLanguage = mainLanguages.find( 11 | (language) => language.id === 'default' 12 | ) 13 | 14 | const languages = [...mainLanguages, ...customLanguages()] 15 | 16 | function getTerm(key: DictionaryKeys, languageId?: string) { 17 | const id = languageId || initialLanguageId() 18 | 19 | const language = 20 | languages.find((language) => language.id === id) || defaultLanguage 21 | 22 | return language?.dictionary[key] || defaultLanguage?.dictionary[key] 23 | } 24 | 25 | return { languages, defaultLanguage, getTerm } 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/i18n/main.ts: -------------------------------------------------------------------------------- 1 | import { userPreferences } from 'shared/store' 2 | import { makeLanguage } from './factory' 3 | 4 | const { languages, defaultLanguage, getTerm } = makeLanguage( 5 | () => userPreferences.get('language', 'default'), 6 | () => userPreferences.get('customLanguages', []) 7 | ) 8 | 9 | export { languages, defaultLanguage, getTerm } 10 | -------------------------------------------------------------------------------- /src/shared/i18n/renderer.ts: -------------------------------------------------------------------------------- 1 | import { makeLanguage } from './factory' 2 | 3 | const { config } = window.MiniVideoMe 4 | 5 | const { languages, defaultLanguage, getTerm } = makeLanguage( 6 | () => config.language || 'default', 7 | () => config.customLanguages 8 | ) 9 | 10 | export { languages, defaultLanguage, getTerm } 11 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * as constants from './constants' 2 | -------------------------------------------------------------------------------- /src/shared/store/default.ts: -------------------------------------------------------------------------------- 1 | import { PLATFORM } from 'shared/constants' 2 | 3 | export function getDefaultStore() { 4 | const { IS_MAC } = PLATFORM 5 | 6 | return { 7 | language: 'default', 8 | theme: 'default', 9 | 10 | customLanguages: [ 11 | { 12 | id: 'custom', 13 | displayName: 'Custom', 14 | 15 | dictionary: { 16 | default: 'Default', 17 | language: 'Language', 18 | topLeft: 'Top left', 19 | topRight: 'Top right', 20 | bottomLeft: 'Bottom left', 21 | bottomRight: 'Bottom right', 22 | small: 'Small', 23 | large: 'Large', 24 | settings: 'Settings', 25 | themes: 'Themes', 26 | windowSize: 'Window Size', 27 | screenEdge: 'Screen Edge', 28 | display: 'Display', 29 | videoInputSource: 'Video Input Source', 30 | close: 'Close', 31 | loading: 'Camera loading', 32 | connected: 'Camera connected', 33 | disconnected: 'Camera disconnected', 34 | notFound: 'Camera not found', 35 | }, 36 | }, 37 | ], 38 | 39 | customThemes: [ 40 | { 41 | id: 'custom', 42 | displayName: 'Custom', 43 | textColor: '#fff', 44 | borderColor: 'linear-gradient(to right, #988BC7, #FF79C6)', 45 | borderWidth: '5px', 46 | }, 47 | ], 48 | 49 | themeOverrides: { 50 | textColor: null, 51 | borderWidth: null, 52 | }, 53 | 54 | hasShadow: true, 55 | 56 | camera: { 57 | width: 1920, 58 | height: 1080, 59 | frameRate: 60, 60 | }, 61 | 62 | anchor: { 63 | x: 0, 64 | y: 0, 65 | }, 66 | 67 | shortcuts: { 68 | moveCamera: { 69 | up: IS_MAC ? 'Shift+Alt+CommandOrControl+Up' : 'Shift+Alt+Up', 70 | down: IS_MAC ? 'Shift+Alt+CommandOrControl+Down' : 'Shift+Alt+Down', 71 | left: IS_MAC ? 'Shift+Alt+CommandOrControl+Left' : 'Shift+Alt+Left', 72 | right: IS_MAC ? 'Shift+Alt+CommandOrControl+Right' : 'Shift+Alt+Right', 73 | }, 74 | 75 | resizeCamera: { 76 | toggle: IS_MAC ? 'Shift+Alt+CommandOrControl+1' : 'Shift+Alt+1', 77 | }, 78 | 79 | hideCamera: IS_MAC ? 'Shift+Alt+CommandOrControl+2' : 'Shift+Alt+2', 80 | 81 | openPreferencesFile: 'CommandOrControl+,', 82 | 83 | adjustCameraOffset: { 84 | left: 'ArrowLeft', 85 | right: 'ArrowRight', 86 | up: 'ArrowUp', 87 | down: 'ArrowDown', 88 | }, 89 | 90 | flipHorizontal: '/', 91 | toggleCam: 'Backspace', 92 | toggleWindowSize: 'Space', 93 | toggleShapes: 'o', 94 | reset: 'r', 95 | 96 | zoom: { 97 | in: '=', 98 | out: '-', 99 | }, 100 | }, 101 | 102 | shapes: [ 103 | 'circle(50% at 50% 50%)', 104 | 'polygon(0 0, 100% 0, 100% 100%, 0% 100%)', 105 | ], 106 | 107 | screen: { 108 | initial: { 109 | width: 300, 110 | height: 300, 111 | }, 112 | 113 | large: { 114 | width: 600, 115 | height: 600, 116 | }, 117 | }, 118 | 119 | zoom: 1.1, 120 | flipHorizontal: false, 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/shared/store/index.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store' 2 | 3 | import { userPreferencesSchema } from './schema' 4 | import { getDefaultStore } from './default' 5 | 6 | export { getDefaultStore } 7 | 8 | export const userPreferences = new Store({ 9 | defaults: getDefaultStore(), 10 | clearInvalidConfig: true, 11 | schema: userPreferencesSchema, 12 | }) 13 | -------------------------------------------------------------------------------- /src/shared/store/schema.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchemaType } from 'json-schema-typed' 2 | import { Schema } from 'electron-store' 3 | 4 | import { getDefaultStore } from './default' 5 | 6 | const defaultStore = getDefaultStore() 7 | 8 | export const userPreferencesSchema: Schema = { 9 | language: { 10 | type: JSONSchemaType.String, 11 | }, 12 | 13 | theme: { 14 | type: JSONSchemaType.String, 15 | }, 16 | 17 | customLanguages: { 18 | type: JSONSchemaType.Array, 19 | items: { 20 | type: JSONSchemaType.Object, 21 | }, 22 | }, 23 | 24 | customThemes: { 25 | type: JSONSchemaType.Array, 26 | items: { 27 | type: JSONSchemaType.Object, 28 | }, 29 | }, 30 | 31 | themeOverrides: { 32 | type: JSONSchemaType.Object, 33 | }, 34 | 35 | hasShadow: { 36 | type: JSONSchemaType.Boolean, 37 | }, 38 | 39 | camera: { 40 | type: JSONSchemaType.Object, 41 | 42 | properties: { 43 | width: { 44 | type: JSONSchemaType.Number, 45 | }, 46 | height: { 47 | type: JSONSchemaType.Number, 48 | }, 49 | frameRate: { 50 | type: JSONSchemaType.Number, 51 | }, 52 | }, 53 | }, 54 | 55 | anchor: { 56 | type: JSONSchemaType.Object, 57 | properties: { 58 | x: { 59 | type: JSONSchemaType.Number, 60 | }, 61 | y: { 62 | type: JSONSchemaType.Number, 63 | }, 64 | }, 65 | }, 66 | 67 | shortcuts: { 68 | type: JSONSchemaType.Object, 69 | properties: { 70 | moveCamera: { 71 | type: JSONSchemaType.Object, 72 | properties: { 73 | up: { 74 | type: JSONSchemaType.String, 75 | }, 76 | down: { 77 | type: JSONSchemaType.String, 78 | }, 79 | left: { 80 | type: JSONSchemaType.String, 81 | }, 82 | right: { 83 | type: JSONSchemaType.String, 84 | }, 85 | }, 86 | }, 87 | 88 | resizeCamera: { 89 | type: JSONSchemaType.Object, 90 | properties: { 91 | initial: { 92 | type: JSONSchemaType.String, 93 | }, 94 | large: { 95 | type: JSONSchemaType.String, 96 | }, 97 | }, 98 | }, 99 | 100 | hideCamera: { 101 | type: JSONSchemaType.String, 102 | }, 103 | }, 104 | }, 105 | 106 | screen: { 107 | type: JSONSchemaType.Object, 108 | properties: { 109 | initial: { 110 | type: JSONSchemaType.Object, 111 | properties: { 112 | width: { 113 | type: JSONSchemaType.Number, 114 | }, 115 | height: { 116 | type: JSONSchemaType.Number, 117 | }, 118 | }, 119 | }, 120 | 121 | large: { 122 | type: JSONSchemaType.Object, 123 | properties: { 124 | width: { 125 | type: JSONSchemaType.Number, 126 | }, 127 | height: { 128 | type: JSONSchemaType.Number, 129 | }, 130 | }, 131 | }, 132 | }, 133 | }, 134 | 135 | shapes: { 136 | type: JSONSchemaType.Array, 137 | items: { 138 | type: JSONSchemaType.String, 139 | }, 140 | }, 141 | 142 | flipHorizontal: { 143 | type: JSONSchemaType.Boolean, 144 | }, 145 | 146 | zoom: { 147 | type: JSONSchemaType.Number, 148 | }, 149 | } 150 | -------------------------------------------------------------------------------- /src/shared/themes/factory.ts: -------------------------------------------------------------------------------- 1 | import * as themesModules from 'themes' 2 | import { Theme } from 'shared/types' 3 | 4 | export function makeTheme(customThemes: Theme[]) { 5 | const mainThemes: Theme[] = Object.values(themesModules) 6 | const defaultTheme = mainThemes.find((theme) => theme.id === 'default') 7 | const themes = [...mainThemes, ...customThemes] 8 | 9 | return { themes, defaultTheme } 10 | } 11 | -------------------------------------------------------------------------------- /src/shared/themes/main.ts: -------------------------------------------------------------------------------- 1 | import { userPreferences } from 'shared/store' 2 | import { makeTheme } from './factory' 3 | 4 | const { themes, defaultTheme } = makeTheme( 5 | userPreferences.get('customThemes', []) 6 | ) 7 | 8 | export { themes, defaultTheme } 9 | -------------------------------------------------------------------------------- /src/shared/themes/renderer.ts: -------------------------------------------------------------------------------- 1 | import { makeTheme } from './factory' 2 | 3 | const { themes, defaultTheme } = makeTheme( 4 | window.MiniVideoMe.config.customThemes 5 | ) 6 | 7 | export { themes, defaultTheme } 8 | -------------------------------------------------------------------------------- /src/shared/types/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindowConstructorOptions, 3 | IpcMainInvokeEvent, 4 | BrowserWindow, 5 | } from 'electron' 6 | 7 | import { defaultLanguage } from 'i18n' 8 | 9 | export type BrowserWindowOrNull = Electron.BrowserWindow | null 10 | 11 | export interface WindowProps extends BrowserWindowConstructorOptions { 12 | id: string 13 | } 14 | 15 | export interface WindowCreationByIPC { 16 | channel: string 17 | window(): BrowserWindowOrNull 18 | callback(window: BrowserWindow, event: IpcMainInvokeEvent): void 19 | } 20 | 21 | export interface VideoDevice { 22 | id: string 23 | label: string 24 | } 25 | 26 | export interface Sizes { 27 | width: number 28 | height: number 29 | } 30 | 31 | export interface Theme { 32 | id: string 33 | displayName: string 34 | textColor?: string 35 | borderColor?: string 36 | borderWidth?: string 37 | } 38 | 39 | export type Language = typeof defaultLanguage 40 | export type DictionaryKeys = keyof Language['dictionary'] 41 | -------------------------------------------------------------------------------- /src/themes/aura.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const AuraTheme: Theme = { 4 | id: 'aura', 5 | displayName: 'Aura', 6 | borderColor: 'linear-gradient(to bottom right, #a277ff, #61ffca)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/borderless.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const BorderlessTheme: Theme = { 4 | id: 'borderless', 5 | displayName: 'Borderless', 6 | borderColor: '#18191c', 7 | borderWidth: '0', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/default.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const DefaultTheme: Theme = { 4 | id: 'default', 5 | displayName: 'Default', 6 | textColor: '#fff', 7 | borderColor: 'linear-gradient(to right, #988BC7, #FF79C6)', 8 | borderWidth: '5px', 9 | } 10 | -------------------------------------------------------------------------------- /src/themes/dusk.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const DuskTheme: Theme = { 4 | id: 'dusk', 5 | displayName: 'Dusk', 6 | borderColor: 'linear-gradient(43deg, #4158D0 0%, #C850C0 46%, #FFCC70 100%)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './borderless' 2 | export * from './launchbase' 3 | export * from './turquoise' 4 | export * from './rainbown' 5 | export * from './scarlet' 6 | export * from './default' 7 | export * from './yellow' 8 | export * from './vivid' 9 | export * from './lemon' 10 | export * from './omni' 11 | export * from './dusk' 12 | export * from './aura' 13 | -------------------------------------------------------------------------------- /src/themes/launchbase.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const LaunchbaseTheme: Theme = { 4 | id: 'launchbase', 5 | displayName: 'Launchbase', 6 | borderColor: 'linear-gradient(to bottom right, #FD951F, #8c7ae6)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/lemon.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const LemonTheme: Theme = { 4 | id: 'lemon', 5 | displayName: 'Lemon', 6 | borderColor: 'linear-gradient(45deg, #85FFBD 0%, #FFFB7D 100%)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/omni.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const OmniTheme: Theme = { 4 | id: 'omni', 5 | displayName: 'Omni', 6 | borderColor: 'linear-gradient(to bottom right, #67E480, #FF79C6)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/rainbown.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const RainbownTheme: Theme = { 4 | id: 'rainbown', 5 | displayName: 'Rainbown', 6 | borderColor: 7 | 'linear-gradient(90deg, rgba(255,0,0,1) 0%, rgba(255,154,0,1) 10%, rgba(208,222,33,1) 20%, rgba(79,220,74,1) 30%, rgba(63,218,216,1) 40%, rgba(47,201,226,1) 50%, rgba(28,127,238,1) 60%, rgba(95,21,242,1) 70%, rgba(186,12,248,1) 80%, rgba(251,7,217,1) 90%, rgba(255,0,0,1) 100%)', 8 | borderWidth: '5px', 9 | } 10 | -------------------------------------------------------------------------------- /src/themes/scarlet.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const ScarletTheme: Theme = { 4 | id: 'scarlet', 5 | displayName: 'Scarlet', 6 | borderColor: 'linear-gradient(to bottom right, #dd2919, #771d16)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/turquoise.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const TurquoiseTheme: Theme = { 4 | id: 'turquoise', 5 | displayName: 'Turquoise', 6 | borderColor: 'linear-gradient(to bottom right, #42d3ff, #2f86a1)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/vivid.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const VividTheme: Theme = { 4 | id: 'vivid', 5 | displayName: 'Vivid', 6 | borderColor: 'linear-gradient(90deg, #00DBDE 0%, #FC00FF 100%)', 7 | borderWidth: '5px', 8 | } 9 | -------------------------------------------------------------------------------- /src/themes/yellow.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from 'shared/types' 2 | 3 | export const YellowTheme: Theme = { 4 | id: 'yellow', 5 | displayName: 'Yellow', 6 | textColor: '#18191c', 7 | borderColor: '#f6db04', 8 | borderWidth: '5px', 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": false, 4 | "target": "esnext", 5 | "lib": [ 6 | "esnext", 7 | "dom", 8 | "dom.iterable" 9 | ], 10 | "jsx": "react-jsx", 11 | "importHelpers": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "sourceMap": true, 16 | "isolatedModules": true, 17 | "allowJs": true, 18 | "allowSyntheticDefaultImports": true, 19 | "skipLibCheck": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "noEmit": true, 22 | "baseUrl": ".", 23 | "paths": { 24 | "*": [ 25 | "./src/*" 26 | ], 27 | "~/*": [ 28 | "./*" 29 | ], 30 | } 31 | }, 32 | "include": [ 33 | "src", 34 | "globals.d.ts", 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ], 39 | "plugins": [ 40 | { 41 | "name": "typescript-plugin-css-modules" 42 | } 43 | ], 44 | } 45 | -------------------------------------------------------------------------------- /webpack/main.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path') 2 | 3 | const { sharedOptions } = require('./shared.config') 4 | const { APP_CONFIG } = require('../app.config') 5 | 6 | const { FOLDERS } = APP_CONFIG 7 | 8 | module.exports = { 9 | target: 'electron-main', 10 | externals: ['fsevents'], 11 | 12 | ...sharedOptions, 13 | 14 | entry: { 15 | main: resolve(FOLDERS.ENTRY_POINTS.MAIN), 16 | bridge: resolve(FOLDERS.ENTRY_POINTS.BRIDGE), 17 | }, 18 | 19 | output: { 20 | path: resolve(FOLDERS.DEV_TEMP_BUILD), 21 | filename: '[name].js', 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /webpack/renderer.config.js: -------------------------------------------------------------------------------- 1 | const HTMLWebpackPlugin = require('html-webpack-plugin') 2 | const CopyWebpackPlugin = require('copy-webpack-plugin') 3 | const webpack = require('webpack') 4 | const { resolve } = require('path') 5 | 6 | const { sharedOptions } = require('./shared.config') 7 | const { isModuleAvailable } = require('./utils') 8 | const { APP_CONFIG } = require('../app.config') 9 | 10 | const { FOLDERS, RENDERER } = APP_CONFIG 11 | 12 | const isSassAvailable = 13 | isModuleAvailable('sass') && isModuleAvailable('sass-loader') 14 | 15 | module.exports = { 16 | target: 'web', 17 | 18 | entry: resolve(FOLDERS.ENTRY_POINTS.RENDERER), 19 | 20 | ...sharedOptions, 21 | 22 | resolve: { 23 | ...sharedOptions.resolve, 24 | alias: { 25 | ...sharedOptions.resolve.alias, 26 | react: resolve('node_modules', 'react'), 27 | }, 28 | }, 29 | 30 | devServer: { 31 | port: RENDERER.DEV_SERVER.URL.split(':')?.[2], 32 | historyApiFallback: true, 33 | compress: true, 34 | hot: true, 35 | client: { 36 | overlay: true, 37 | }, 38 | }, 39 | 40 | output: { 41 | path: resolve(FOLDERS.DEV_TEMP_BUILD), 42 | filename: 'renderer.js', 43 | }, 44 | 45 | module: { 46 | rules: [ 47 | ...sharedOptions.module.rules, 48 | 49 | { 50 | test: /\.css$/, 51 | use: ['style-loader', 'css-loader'], 52 | }, 53 | 54 | isSassAvailable && { 55 | test: /\.s(a|c)ss$/, 56 | use: [ 57 | 'style-loader', 58 | { 59 | loader: 'css-loader', 60 | options: { modules: true }, 61 | }, 62 | 'sass-loader', 63 | ], 64 | include: /\.module\.s(a|c)ss$/, 65 | }, 66 | 67 | isSassAvailable && { 68 | test: /\.s(a|c)ss$/, 69 | use: ['style-loader', 'css-loader', 'sass-loader'], 70 | exclude: /\.module\.s(a|c)ss$/, 71 | }, 72 | 73 | { 74 | test: /\.(woff|woff2|eot|ttf|otf|png|jpe?g|gif)$/, 75 | use: ['file-loader'], 76 | }, 77 | 78 | { 79 | test: /\.svg$/, 80 | issuer: /\.[jt]sx?$/, 81 | loader: '@svgr/webpack', 82 | }, 83 | ].filter(Boolean), 84 | }, 85 | 86 | plugins: [ 87 | ...sharedOptions.plugins, 88 | 89 | new CopyWebpackPlugin({ 90 | patterns: [ 91 | { 92 | from: resolve(FOLDERS.RESOURCES), 93 | to: resolve(FOLDERS.DEV_TEMP_BUILD, 'resources'), 94 | }, 95 | ], 96 | }), 97 | 98 | new webpack.DefinePlugin({ 99 | process: JSON.stringify({ 100 | platform: process.platform, 101 | }), 102 | }), 103 | 104 | new HTMLWebpackPlugin({ 105 | template: resolve(FOLDERS.INDEX_HTML), 106 | }), 107 | 108 | // new webpack.DefinePlugin({ 109 | // __REACT_DEVTOOLS_GLOBAL_HOOK__: '({ isDisabled: true })', 110 | // }), 111 | ], 112 | } 113 | -------------------------------------------------------------------------------- /webpack/shared.config.js: -------------------------------------------------------------------------------- 1 | const SimpleProgressWebpackPlugin = require('simple-progress-webpack-plugin') 2 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin') 3 | const { resolve } = require('path') 4 | 5 | const { isDev } = require('./utils') 6 | 7 | exports.sharedOptions = { 8 | mode: process.env.NODE_ENV || 'development', 9 | 10 | stats: 'minimal', 11 | 12 | devtool: isDev ? 'eval-source-map' : 'source-map', 13 | 14 | resolve: { 15 | extensions: ['.tsx', '.ts', '.js', '.jsx'], 16 | 17 | alias: { 18 | '~': resolve(), 19 | }, 20 | 21 | plugins: [new TsconfigPathsPlugin({})], 22 | }, 23 | 24 | optimization: { 25 | usedExports: true, 26 | }, 27 | 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(js|ts|tsx|jsx)$/, 32 | loader: 'swc-loader', 33 | exclude: /node_modules/, 34 | }, 35 | ], 36 | }, 37 | 38 | plugins: [new SimpleProgressWebpackPlugin({ format: 'minimal' })], 39 | } 40 | -------------------------------------------------------------------------------- /webpack/utils.js: -------------------------------------------------------------------------------- 1 | exports.isDev = process.env.NODE_ENV !== 'production' 2 | 3 | exports.isModuleAvailable = (moduleName) => { 4 | try { 5 | return Boolean(require.resolve(moduleName)) 6 | } catch { 7 | return false 8 | } 9 | } 10 | --------------------------------------------------------------------------------