├── .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 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 |
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 | MacOS
63 | Linux / Windows
64 | Function
65 | Window must be focused
66 |
67 |
68 |
69 |
70 | + / -
71 | + / -
72 | Zoom in/out
73 | Yes
74 |
75 |
76 | /
77 | /
78 | Flip horizontal
79 | Yes
80 |
81 |
82 | o
83 | o
84 | Toggle custom shapes
85 | Yes
86 |
87 |
88 | r
89 | r
90 | Reset zoom
91 | Yes
92 |
93 |
94 | Backspace
95 | Backspace
96 | Switch cam
97 | Yes
98 |
99 |
100 | Space
101 | Space
102 | Toggle window size (small/large)
103 | Yes
104 |
105 |
106 | Command + ,
107 | Ctrl + ,
108 | Open the settings file
109 | Yes
110 |
111 |
112 | Arrow Up / Down / Left / Right
113 | Arrow Up / Down / Left / Right
114 | Adjust video offset
115 | Yes
116 |
117 |
118 | Command + Shift + Alt + Up
119 | Shift + Alt + Up
120 | Move camera to upper screen edge
121 | No
122 |
123 |
124 | Command + Shift + Alt + Down
125 | Shift + Alt + Down
126 | Move camera to lower screen edge
127 | No
128 |
129 |
130 | Command + Shift + Alt + Right
131 | Shift + Alt + Right
132 | Move camera to right screen edge
133 | No
134 |
135 |
136 | Command + Shift + Alt + 1
137 | Shift + Alt + 1
138 | Toggle camera size (small/large)
139 | No
140 |
141 |
142 | Command + Shift + Alt + 3
143 | Shift + Alt + 2
144 | Toggle camera visibility (show/hide)
145 | No
146 |
147 |
148 |
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 |
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 |
--------------------------------------------------------------------------------