├── .editorconfig ├── .gitattributes ├── .github ├── add-macos-certificate.sh └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore ├── countdown-new.iml ├── modules.xml └── vcs.xml ├── LICENSE ├── README.md ├── docs └── images │ ├── countdown-going.png │ ├── countdown.png │ ├── main.png │ └── settings.png ├── eslint.config.mjs ├── forge.config.ts ├── package.json ├── postcss.config.js ├── src ├── common │ ├── IpcInterfaces.ts │ ├── TimerInterfaces.ts │ └── config.ts ├── icons │ ├── icon.icns │ ├── icon.ico │ └── tray │ │ ├── TrayTemplate.png │ │ └── TrayTemplate@2x.png ├── main │ ├── App.ts │ ├── Migrations │ │ ├── BaseMigration.ts │ │ ├── MergeOpacityToBackgroundColor.ts │ │ ├── MoveBlackAtResetToContentAtReset.ts │ │ ├── MoveSettingsToWindow.ts │ │ ├── RemoveFont.ts │ │ └── applyMigrations.ts │ ├── Remotes │ │ ├── HTTP.ts │ │ ├── IpcTimerController.ts │ │ ├── NDI.ts │ │ └── OSC.ts │ ├── TimerEngine.ts │ ├── Utilities │ │ ├── AdjustingInterval.ts │ │ ├── BrowserWinHandler.ts │ │ ├── Config.ts │ │ ├── ImageBufferAdjustment.js │ │ ├── Timer.ts │ │ ├── TimersOrchestrator.ts │ │ ├── addDefaultEvents.ts │ │ ├── addIpcHandles.ts │ │ ├── dev.ts │ │ ├── setMenu.js │ │ └── utilities.ts │ ├── countdownWindow.ts │ ├── index.ts │ └── mainWindow.ts └── renderer │ ├── App.vue │ ├── TimerControl.ts │ ├── assets │ ├── fonts │ │ ├── B612Mono-Regular.ttf │ │ ├── XanhMono-Regular.ttf │ │ └── digital-7.woff2 │ ├── images │ │ └── logo.png │ └── sounds │ │ └── gong.mp3 │ ├── components │ ├── BaseContainer.vue │ ├── Card.vue │ ├── CheckBox.vue │ ├── Clock.vue │ ├── ColorInput.vue │ ├── CreateTimerModal.vue │ ├── DeleteTimerModal.vue │ ├── Drawer.vue │ ├── EditPreset.vue │ ├── EditTimerModal.vue │ ├── InputWithButton.vue │ ├── Jog.vue │ ├── Navigation.vue │ ├── ProgressBar.vue │ ├── RemoteTimer.vue │ ├── SButton.vue │ ├── ScreensDrag.vue │ ├── SidebarMenu.vue │ ├── StoresUpdater.vue │ ├── TabButton.vue │ ├── TimeInput.vue │ ├── TimerTabButton.vue │ ├── TimersNavigation.vue │ └── TopBar.vue │ ├── index.css │ ├── index.html │ ├── index.ts │ ├── pages │ ├── Control.vue │ ├── Countdown.vue │ ├── GeneralSettings.vue │ ├── RemoteSettings.vue │ └── TimersSettings.vue │ ├── piniaHmr.ts │ └── stores │ ├── global.ts │ ├── settings.ts │ └── timers.ts ├── tailwind.config.js ├── tsconfig.json ├── types └── index.d.ts ├── vitest.config.ts ├── webpack.main.config.ts ├── webpack.plugins.ts ├── webpack.renderer.config.ts ├── webpack.rules.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # https://git-scm.com/docs/gitattributes 2 | # 3 | # A gitattributes file is a simple text file that gives attributes to path names. 4 | # Each line in gitattributes file is of form: pattern attr1 attr2 ... 5 | # That is, a pattern followed by an attributes list, separated by white spaces. 6 | # When the pattern matches the path in question, the attributes listed on the line are given to the path. 7 | # Each attribute can be in one of these states for a given path: 8 | # FIX CRLF always when developer has not set 9 | # Linux/Mac: git config --global core.autocrlf input 10 | # Windows: git config --global core.autocrlf true 11 | # Auto detect text files and perform LF normalization 12 | * text=auto 13 | * text eol=lf 14 | 15 | # git objects as binary 16 | *.png binary 17 | *.ico binary 18 | *.icns binary 19 | *.vue text 20 | 21 | # git objects as text for diff 22 | *.md text 23 | *.js text 24 | *.yml text 25 | .editorconfig text 26 | .gitattributes text 27 | .gitignore text 28 | -------------------------------------------------------------------------------- /.github/add-macos-certificate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | KEY_CHAIN=build.keychain 4 | CERTIFICATE_P12=certificate.p12 5 | 6 | # Recreate the certificate from the secure environment variable 7 | echo $APPLE_CERTIFICATES | base64 --decode > $CERTIFICATE_P12 8 | 9 | #create a keychain 10 | security create-keychain -p actions $KEY_CHAIN 11 | 12 | # Make the keychain the default so identities are found 13 | security default-keychain -s $KEY_CHAIN 14 | 15 | # Unlock the keychain 16 | security unlock-keychain -p actions $KEY_CHAIN 17 | 18 | security import $CERTIFICATE_P12 -k $KEY_CHAIN -P $APPLE_ID_PASSWORD -T /usr/bin/codesign; 19 | 20 | security set-key-partition-list -S apple-tool:,apple: -s -k actions $KEY_CHAIN 21 | 22 | # remove certs 23 | rm -fr *.p12 24 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/Publish artifacts 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest, ubuntu-22.04-arm] 15 | 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v1 19 | 20 | - name: Install Node.js, NPM and Yarn 21 | uses: actions/setup-node@v4 22 | with: 23 | cache: 'yarn' 24 | node-version: 20 25 | 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: '3.10' 29 | 30 | - name: Install Python setup tools 31 | run: | 32 | pip install setuptools 33 | 34 | - name: Install dependencies 35 | run: yarn 36 | 37 | - name: Add macOS certs 38 | if: matrix.os == 'macos-latest' 39 | run: chmod +x ./.github/add-macos-certificate.sh && ./.github/add-macos-certificate.sh 40 | env: 41 | APPLE_CERTIFICATES: ${{ secrets.APPLE_CERTIFICATES }} 42 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 43 | 44 | - name: Build x64 45 | run: | 46 | yarn make --arch x64 47 | env: 48 | APPLE_ID: ${{ secrets.APPLE_ID }} 49 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 50 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 51 | - name: Build arm64 52 | if: ${{ runner.arch == 'ARM64' }} 53 | run: | 54 | yarn make --arch arm64 55 | env: 56 | APPLE_ID: ${{ secrets.APPLE_ID }} 57 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 58 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 59 | 60 | - name: Upload macOS artifacts 61 | uses: actions/upload-artifact@v4 62 | if: ${{ runner.os == 'macOS' }} 63 | with: 64 | name: macos-build 65 | path: | 66 | out/make/**/*.zip 67 | out/make/**/*.dmg 68 | - name: Upload Windows artifacts 69 | uses: actions/upload-artifact@v4 70 | if: ${{ runner.os == 'Windows' }} 71 | with: 72 | name: windows-build 73 | path: | 74 | out/make/**/*.nupkg 75 | out/make/**/*.exe 76 | - name: Upload Linux artifacts 77 | uses: actions/upload-artifact@v4 78 | if: ${{ runner.os == 'Linux' }} 79 | with: 80 | name: linux-build 81 | path: | 82 | out/make/**/*.rpm 83 | out/make/**/*.deb 84 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | os: [macos-latest, ubuntu-latest, windows-latest] 16 | 17 | steps: 18 | - name: Check out Git repository 19 | uses: actions/checkout@v1 20 | 21 | - name: Install Node.js, NPM and Yarn 22 | uses: actions/setup-node@v4 23 | with: 24 | cache: 'yarn' 25 | node-version: 20 26 | 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: '3.10' 30 | 31 | - name: Install Python setup tools 32 | run: | 33 | pip install setuptools 34 | 35 | - name: Install dependencies 36 | run: yarn 37 | 38 | - name: Add macOS certs 39 | if: runner.os == 'macOS' 40 | run: chmod +x ./.github/add-macos-certificate.sh && ./.github/add-macos-certificate.sh 41 | env: 42 | APPLE_CERTIFICATES: ${{ secrets.APPLE_CERTIFICATES }} 43 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 44 | 45 | - name: Publish x64 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | run: yarn run publish --arch x64 49 | 50 | - name: Publish arm64 51 | if: ${{ runner.os == 'macOS' }} 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | APPLE_ID: ${{ secrets.APPLE_ID }} 55 | APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 56 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 57 | run: yarn run publish --arch arm64 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless/ 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | # DynamoDB Local files 83 | .dynamodb/ 84 | 85 | # Webpack 86 | .webpack/ 87 | 88 | # Electron-Forge 89 | out/ 90 | 91 | src/version.ts 92 | build 93 | .idea 94 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/countdown-new.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Countdown 2 | 3 | Simple countdown for live events 4 | 5 | Controllable by Companion 6 | 7 | ### Features 8 | 9 | - NDI support 10 | - OSC support 11 | - HTTP/Companion support 12 | - Send messages 13 | - Custom colors 14 | - Many customization options 15 | 16 | ### Download 17 | 18 | You can download latest release from [releases](https://github.com/CVMEventi/Countdown/releases) 19 | 20 | ### APIs 21 | 22 | - [HTTP APIs](https://github.com/CVMEventi/Countdown/wiki/HTTP-APIs) 23 | - OSC 24 | 25 | ### Images (To be updated) 26 | Main window to set timer 27 | ![Main Window](./docs/images/main.png) 28 | 29 | Settings window 30 | ![Setting Window](./docs/images/settings.png) 31 | 32 | Countdown going 33 | ![Countdown Window going](./docs/images/countdown-going.png) 34 | 35 | Countdown stopped 36 | ![Countdown Window stopped](./docs/images/countdown.png) 37 | 38 | #### Build Setup 39 | 40 | ``` bash 41 | # install dependencies 42 | yarn 43 | 44 | # serve app with hot reload 45 | yarn start 46 | 47 | # build electron application for production 48 | yarn make 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/images/countdown-going.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/docs/images/countdown-going.png -------------------------------------------------------------------------------- /docs/images/countdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/docs/images/countdown.png -------------------------------------------------------------------------------- /docs/images/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/docs/images/main.png -------------------------------------------------------------------------------- /docs/images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/docs/images/settings.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tselint from 'typescript-eslint' 3 | import importPlugin from 'eslint-plugin-import' 4 | import globals from 'globals' 5 | 6 | export default tselint.config( 7 | { 8 | ignores: [".webpack/", ".vite/"], 9 | }, 10 | { 11 | plugins: { 12 | import: importPlugin 13 | }, 14 | extends: [ 15 | eslint.configs.recommended, 16 | ...tselint.configs.recommended, 17 | ], 18 | languageOptions: { 19 | globals: { 20 | ...globals.browser, 21 | ...globals.node, 22 | } 23 | }, 24 | rules: { 25 | "import/extensions": ["error", "always"] 26 | }, 27 | settings: { 28 | "import/resolver": { 29 | node: { 30 | extensions: [".js", ".jsx", ".ts", ".tsx", ".vue"], 31 | }, 32 | } 33 | } 34 | } 35 | ) 36 | 37 | /* 38 | export default [...fixupConfigRules(compat.extends( 39 | "eslint:recommended", 40 | "plugin:@typescript-eslint/eslint-recommended", 41 | "plugin:@typescript-eslint/recommended", 42 | "plugin:import/errors", 43 | "plugin:import/warnings", 44 | "plugin:import/typescript", 45 | )), { 46 | languageOptions: { 47 | globals: { 48 | ...globals.browser, 49 | ...globals.node, 50 | }, 51 | 52 | parser: tsParser, 53 | }, 54 | 55 | settings: { 56 | "import/resolver": { 57 | node: { 58 | extensions: [".js", ".jsx", ".ts", ".tsx", ".vue"], 59 | }, 60 | }, 61 | }, 62 | }];*/ 63 | -------------------------------------------------------------------------------- /forge.config.ts: -------------------------------------------------------------------------------- 1 | import type { ForgeConfig } from '@electron-forge/shared-types'; 2 | import { MakerSquirrel } from '@electron-forge/maker-squirrel'; 3 | import { MakerZIP } from '@electron-forge/maker-zip'; 4 | import { MakerDeb } from '@electron-forge/maker-deb'; 5 | import { MakerRpm } from '@electron-forge/maker-rpm'; 6 | import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives'; 7 | import { WebpackPlugin } from '@electron-forge/plugin-webpack'; 8 | 9 | import { mainConfig } from './webpack.main.config'; 10 | import { rendererConfig } from './webpack.renderer.config'; 11 | 12 | import * as fs from "fs"; 13 | import * as path from "path"; 14 | import { APP_VERSION } from "./src/version"; 15 | import MakerDMG from "@electron-forge/maker-dmg"; 16 | import PublisherGithub from "@electron-forge/publisher-github"; 17 | 18 | const appName = "Countdown"; 19 | 20 | const config: ForgeConfig = { 21 | packagerConfig: { 22 | asar: { 23 | unpack: "**/node_modules/grandiose/**/*", 24 | }, 25 | osxSign: {}, 26 | osxNotarize: { 27 | appleId: process.env.APPLE_ID, 28 | appleIdPassword: process.env.APPLE_ID_PASSWORD, 29 | teamId: process.env.APPLE_TEAM_ID 30 | }, 31 | icon: "src/icons/icon.icns", 32 | win32metadata: { 33 | "CompanyName": "CVM Eventi", 34 | "ProductName": "Countdown" 35 | }, 36 | appCategoryType: "public.app-category.utilities", 37 | name: appName, 38 | executableName: "countdown", 39 | derefSymlinks: true, 40 | }, 41 | rebuildConfig: {}, 42 | makers: [ 43 | new MakerSquirrel({ 44 | name: "countdown", 45 | }), 46 | new MakerZIP({}, ['darwin']), 47 | new MakerRpm({}), 48 | new MakerDeb({}), 49 | new MakerDMG(), 50 | ], 51 | plugins: [ 52 | new AutoUnpackNativesPlugin({}), 53 | new WebpackPlugin({ 54 | mainConfig, 55 | renderer: { 56 | config: rendererConfig, 57 | entryPoints: [ 58 | { 59 | html: './src/renderer/index.html', 60 | js: './src/renderer/index.ts', 61 | name: 'main_window', 62 | }, 63 | ], 64 | }, 65 | loggerPort: 9050 66 | }), 67 | { 68 | name: "@timfish/forge-externals-plugin", 69 | config: { 70 | "externals": ["grandiose"], 71 | "includeDeps": true, 72 | } 73 | } 74 | ], 75 | publishers: [ 76 | new PublisherGithub({ 77 | repository: { 78 | owner: "CVMEventi", 79 | name: "Countdown" 80 | } 81 | }) 82 | ], 83 | hooks: { 84 | packageAfterPrune: async (config, buildPath, electronVersion, platform) => { 85 | if (platform !== 'win32') { 86 | const binsPath = 'node_modules/grandiose/build/node_gyp_bins/python3' 87 | try { 88 | fs.unlinkSync(path.join(buildPath, binsPath)); 89 | } catch (e) { 90 | console.log("Nothing to delete"); 91 | } 92 | } 93 | }, 94 | postMake: async (forgeConfig, results) => { 95 | if (process.env.CI) { 96 | const version = APP_VERSION; 97 | 98 | return results.map((result) => { 99 | let currentArch = result.arch; 100 | if (result.arch === "ia32") { 101 | currentArch = "x86"; 102 | } 103 | 104 | result.artifacts = result.artifacts.map((artifact) => { 105 | if (artifact.includes('RELEASES')) { 106 | const newName = `${appName}-Windows-${currentArch}-${version}.nupkg`; 107 | 108 | fs.readFile(artifact, 'utf8', function (err,data) { 109 | if (err) { 110 | return console.log(err); 111 | } 112 | const result = data.replace(/(\S+)\s\S+\s(\S+)/g, `$1 ${newName} $2`); 113 | 114 | fs.writeFile(artifact, result, 'utf8', function (err) { 115 | if (err) return console.log(err); 116 | }); 117 | }); 118 | 119 | return artifact; 120 | } 121 | 122 | let os = 'Unknown'; 123 | if (artifact.includes('deb') 124 | || artifact.includes('rpm')) { 125 | os = 'Linux'; 126 | } else if (artifact.includes("dmg") 127 | || artifact.includes("zip")) { 128 | os = 'macOS'; 129 | } else if (artifact.includes("msi") 130 | || artifact.includes("exe") 131 | || artifact.includes("nupkg")) { 132 | os = 'Windows'; 133 | } 134 | const extension = path.extname(artifact) 135 | const newName = `${appName}-${os}-${currentArch}-${version}${extension}`; 136 | const outputDir = path.dirname(artifact); 137 | fs.renameSync(artifact, path.join(outputDir, newName)); 138 | return path.join(outputDir, newName); 139 | }) 140 | 141 | return result; 142 | }) 143 | } 144 | }, 145 | } 146 | }; 147 | 148 | export default config; 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "countdown", 3 | "productName": "countdown", 4 | "version": "0.0.38", 5 | "description": "CVM Countdown", 6 | "main": ".webpack/main", 7 | "repository": "CVMEventi/Countdown", 8 | "scripts": { 9 | "preinstall": "node -p \"'export const APP_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", 10 | "start": "electron-forge start", 11 | "package": "cross-env NODE_ENV=production electron-forge package", 12 | "make": "cross-env NODE_ENV=production electron-forge make", 13 | "publish": "electron-forge publish", 14 | "lint": "eslint .", 15 | "test": "vitest" 16 | }, 17 | "engines": { 18 | "node": ">=20" 19 | }, 20 | "keywords": [], 21 | "author": { 22 | "name": "Matteo Piccina", 23 | "email": "matteo.piccina@cvm.it" 24 | }, 25 | "license": "MIT", 26 | "config": {}, 27 | "devDependencies": { 28 | "@electron-forge/cli": "^7.6.1", 29 | "@electron-forge/maker-deb": "^7.6.1", 30 | "@electron-forge/maker-dmg": "^7.6.1", 31 | "@electron-forge/maker-rpm": "^7.6.1", 32 | "@electron-forge/maker-squirrel": "^7.6.1", 33 | "@electron-forge/maker-zip": "^7.6.1", 34 | "@electron-forge/plugin-auto-unpack-natives": "^7.6.1", 35 | "@electron-forge/plugin-webpack": "^7.6.1", 36 | "@electron-forge/publisher-github": "^7.6.1", 37 | "@eslint/compat": "^1.1.1", 38 | "@eslint/eslintrc": "^3.1.0", 39 | "@eslint/js": "^9.7.0", 40 | "@tailwindcss/postcss": "^4.0.0", 41 | "@timfish/forge-externals-plugin": "^0.2.1", 42 | "@types/eslint__js": "^8.42.3", 43 | "@types/howler": "^2.2.12", 44 | "@types/node": "^22", 45 | "@types/node-osc": "^6.0.1", 46 | "@typescript-eslint/eslint-plugin": "^8.22.0", 47 | "@typescript-eslint/parser": "^8.22.0", 48 | "@vercel/webpack-asset-relocator-loader": "1.7.3", 49 | "@vue/compiler-sfc": "^3.2.31", 50 | "autoprefixer": "^10.4.20", 51 | "cross-env": "^7.0.3", 52 | "css-loader": "^7.1.2", 53 | "css-minimizer-webpack-plugin": "^7.0.0", 54 | "electron": "^34.0.1", 55 | "electron-devtools-installer": "^4.0.0", 56 | "eslint": "^9.19.0", 57 | "eslint-plugin-import": "^2.27.5", 58 | "file-loader": "^6.2.0", 59 | "fork-ts-checker-webpack-plugin": "^9.0.0", 60 | "globals": "^15.14.0", 61 | "mini-css-extract-plugin": "^2.9.2", 62 | "node-loader": "^2.1.0", 63 | "postcss": "^8.5.1", 64 | "postcss-loader": "^8.1.1", 65 | "style-loader": "^4.0.0", 66 | "tailwindcss": "^3.4", 67 | "ts-loader": "^9.5.2", 68 | "ts-node": "^10.9.1", 69 | "typescript": "^5.7.3", 70 | "typescript-eslint": "^8.22.0", 71 | "vitest": "^3.0.4", 72 | "vue-loader": "^17.0.0", 73 | "webpack": "^5.97.1" 74 | }, 75 | "dependencies": { 76 | "@fastify/websocket": "^11.0.2", 77 | "@headlessui-float/vue": "^0.15.0", 78 | "@headlessui/vue": "^1.7.14", 79 | "@heroicons/vue": "^2.0.16", 80 | "@tailwindcss/forms": "^0.5.10", 81 | "@vueuse/core": "^12.5.0", 82 | "dayjs": "^1.11.13", 83 | "dot-prop": "^9.0.0", 84 | "electron-squirrel-startup": "^1.0.0", 85 | "electron-store": "^9.0.0", 86 | "fastify": "^5.2.1", 87 | "grandiose": "github:CVMEventi/grandiose#13df9f2", 88 | "howler": "^2.2.3", 89 | "mime": "^4.0.6", 90 | "node-osc": "^9.1.4", 91 | "pinia": "^2.3.1", 92 | "ulid": "^2.3.0", 93 | "update-electron-app": "^3.1.0", 94 | "v8-compile-cache": "^2.3.0", 95 | "vue": "^3.5.13", 96 | "vue-accessible-color-picker": "^5.2.0", 97 | "vue-router": "^4.5.0", 98 | "vuedraggable": "^4.1.0", 99 | "ws": "^8.5.0" 100 | }, 101 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 102 | } 103 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } -------------------------------------------------------------------------------- /src/common/IpcInterfaces.ts: -------------------------------------------------------------------------------- 1 | export enum IpcTimerCommandName { 2 | SetSeconds, 3 | Start, 4 | Reset, 5 | TogglePause, 6 | Pause, 7 | Resume, 8 | JogSet, 9 | JogCurrent, 10 | Message, 11 | } 12 | 13 | export interface IpcSetSeconds { 14 | name: IpcTimerCommandName.SetSeconds 15 | timerId: string 16 | seconds: number 17 | } 18 | 19 | export interface IpcStart { 20 | name: IpcTimerCommandName.Start 21 | timerId: string 22 | } 23 | 24 | export interface IpcReset { 25 | name: IpcTimerCommandName.Reset 26 | timerId: string 27 | } 28 | 29 | export interface IpcTogglePause { 30 | name: IpcTimerCommandName.TogglePause 31 | timerId: string 32 | } 33 | 34 | export interface IpcPause { 35 | name: IpcTimerCommandName.Pause 36 | timerId: string 37 | } 38 | 39 | export interface IpcResume { 40 | name: IpcTimerCommandName.Resume 41 | timerId: string 42 | } 43 | 44 | export interface IpcJogSet { 45 | name: IpcTimerCommandName.JogSet 46 | timerId: string 47 | seconds: number 48 | } 49 | 50 | export interface IpcJogCurrent { 51 | name: IpcTimerCommandName.JogCurrent 52 | timerId: string 53 | seconds: number 54 | } 55 | 56 | export interface IpcMessage { 57 | name: IpcTimerCommandName.Message 58 | timerId: string 59 | message?: string 60 | } 61 | 62 | export type IpcTimerCommand = IpcSetSeconds | IpcStart | IpcReset | IpcTogglePause | IpcPause | IpcResume | IpcJogSet | IpcJogCurrent | IpcMessage; 63 | 64 | export interface IpcGetWindowSettingsArgs { 65 | timerId: string 66 | windowId: string 67 | } 68 | -------------------------------------------------------------------------------- /src/common/TimerInterfaces.ts: -------------------------------------------------------------------------------- 1 | export interface TimerEngineUpdate { 2 | setSeconds: number 3 | secondsSetOnCurrentTimer: number 4 | currentSeconds: number 5 | countSeconds: number 6 | extraSeconds: number 7 | isExpiring: boolean 8 | isRunning: boolean 9 | isReset: boolean 10 | isCountingUp: boolean 11 | timerEndsAt: string 12 | } 13 | 14 | export interface TimerEngineUpdates { 15 | [key: string]: TimerEngineUpdate 16 | } 17 | 18 | export interface MessageUpdate { 19 | timerId: string 20 | message?: string 21 | } 22 | 23 | export interface WebSocketUpdate { 24 | type: string 25 | update: PayloadType 26 | } 27 | 28 | export interface TimerEngineWebSocketUpdate { 29 | timerId?: string, 30 | state: string 31 | setTime: number, 32 | setTimeHms: string, 33 | setTimeMs: string, 34 | setTimeH: string, 35 | setTimeM: string, 36 | setTimeS: string, 37 | currentTimeHms?: string 38 | currentTimeMs?: string 39 | currentTimeH?: string 40 | currentTimeM?: string 41 | currentTimeS?: string 42 | currentTime?: number 43 | timeSetOnCurrentTimer?: number 44 | timeSetOnCurrentTimerHms?: string 45 | timeSetOnCurrentTimerMs?: string 46 | timeSetOnCurrentTimerH?: string 47 | timeSetOnCurrentTimerM?: string 48 | timeSetOnCurrentTimerS?: string 49 | timerEndsAt?: string 50 | } 51 | 52 | export type UpdateCallback = (update: TimerEngineUpdate) => void; 53 | export type WebSocketUpdateCallback = (update: TimerEngineWebSocketUpdate) => void; 54 | export type MessageUpdateCallback = (update: MessageUpdate) => void; 55 | export type PlaySoundCallback = (audioFilePath: string) => void; 56 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | import {ulid} from 'ulid' 2 | 3 | export const CURRENT_CONFIG_VERSION: number = 2 4 | 5 | export const DEFAULT_TIMER_NAME = 'Timer' 6 | 7 | export const DEFAULT_BACKGROUND_COLOR = '#000000ff'; 8 | export const DEFAULT_RESET_BACKGROUND_COLOR = '#000000ff'; 9 | export const DEFAULT_BACKGROUND_OPACITY = '255'; 10 | export const DEFAULT_TEXT_COLOR = '#ffffff'; 11 | export const DEFAULT_TIMER_FINISHED_TEXT_COLOR = '#ff0000'; 12 | export const DEFAULT_CLOCK_COLOR = '#ffffff'; 13 | export const DEFAULT_CLOCK_TEXT_COLOR = '#ffffff'; 14 | 15 | export const DEFAULT_PRESETS = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]; 16 | 17 | export const DEFAULT_WEBSERVER_ENABLED = true; 18 | export const DEFAULT_WEBSERVER_PORT = 6565; 19 | 20 | export const DEFAULT_NDI_ENABLED = false; 21 | export const DEFAULT_NDI_ALPHA = false; 22 | 23 | export const DEFAULT_OSC_ENABLED = false; 24 | export const DEFAULT_OSC_PORT = 6566; 25 | 26 | export const DEFAULT_STOP_TIMER_AT_ZERO = false; 27 | export const DEFAULT_SHOW_HOURS = false; 28 | export const DEFAULT_PULSE_AT_ZERO = false; 29 | export const DEFAULT_BLACK_AT_RESET = false; 30 | export enum ContentAtReset { 31 | Empty = "EMPTY", 32 | Time = "TIME", 33 | Full = "FULL", 34 | } 35 | export const DEFAULT_CONTENT_AT_RESET: ContentAtReset = ContentAtReset.Full; 36 | export const DEFAULT_TIMER_ALWAYS_ON_TOP = false; 37 | export const DEFAULT_SET_WINDOW_ALWAYS_ON_TOP = false; 38 | export const DEFAULT_YELLOW_AT_OPTION = 'minutes'; 39 | export const DEFAULT_YELLOW_AT_MINUTES = 2; 40 | export const DEFAULT_YELLOW_AT_PERCENT = 10; 41 | 42 | export const DEFAULT_SET_TIME_LIVE = false; 43 | 44 | export const DEFAULT_USE_12_HOUR_CLOCK = false; 45 | 46 | export const DEFAULT_TIMER_DURATION = 1000; 47 | 48 | export const DEFAULT_MESSAGE_BOX_FIXED_HEIGHT = false; 49 | 50 | export const DEFAULT_START_HIDDEN = false; 51 | 52 | export enum CloseAction { 53 | Ask = "ASK", 54 | Hide = "HIDE", 55 | Close = "CLOSE", 56 | } 57 | export const DEFAULT_CLOSE_ACTION: CloseAction = CloseAction.Ask 58 | 59 | export const DEFAULT_SHOW_SECTIONS: ShowSections = { 60 | timer: true, 61 | progress: true, 62 | clock: true, 63 | secondsOnClock: false, 64 | hours: DEFAULT_SHOW_HOURS 65 | }; 66 | 67 | export const DEFAULT_WINDOW_BOUNDS: WindowBounds = { 68 | alwaysOnTop: DEFAULT_TIMER_ALWAYS_ON_TOP, 69 | fullscreenOn: null, 70 | x: 100, 71 | y: 100, 72 | width: 1280, 73 | height: 720 74 | } 75 | 76 | export const DEFAULT_TIMER_COLORS: WindowColors = { 77 | background: DEFAULT_BACKGROUND_COLOR, 78 | resetBackground: DEFAULT_RESET_BACKGROUND_COLOR, 79 | text: DEFAULT_TEXT_COLOR, 80 | timerFinishedText: DEFAULT_TIMER_FINISHED_TEXT_COLOR, 81 | clock: DEFAULT_CLOCK_COLOR, 82 | clockText: DEFAULT_CLOCK_TEXT_COLOR, 83 | } 84 | 85 | export const DEFAULT_WINDOW_SETTINGS: WindowSettings = { 86 | bounds: DEFAULT_WINDOW_BOUNDS, 87 | show: DEFAULT_SHOW_SECTIONS, 88 | messageBoxFixedHeight: DEFAULT_MESSAGE_BOX_FIXED_HEIGHT, 89 | contentAtReset: DEFAULT_CONTENT_AT_RESET, 90 | colors: DEFAULT_TIMER_COLORS, 91 | pulseAtZero: DEFAULT_PULSE_AT_ZERO, 92 | use12HourClock: DEFAULT_USE_12_HOUR_CLOCK, 93 | } 94 | 95 | export const DEFAULT_TIMER_SETTINGS: TimerSettings = { 96 | name: DEFAULT_TIMER_NAME, 97 | yellowAtOption: DEFAULT_YELLOW_AT_OPTION, 98 | yellowAtMinutes: DEFAULT_YELLOW_AT_MINUTES, 99 | yellowAtPercent: DEFAULT_YELLOW_AT_PERCENT, 100 | timerDuration: DEFAULT_TIMER_DURATION, 101 | setTimeLive: DEFAULT_SET_TIME_LIVE, 102 | stopTimerAtZero: DEFAULT_STOP_TIMER_AT_ZERO, 103 | audioFile: null, 104 | windows: {[ulid()]: DEFAULT_WINDOW_SETTINGS}, 105 | } 106 | 107 | export const DEFAULT_REMOTE_SETTINGS: RemoteSettings = { 108 | webServerEnabled: DEFAULT_WEBSERVER_ENABLED, 109 | webServerPort: DEFAULT_WEBSERVER_PORT, 110 | ndiEnabled: DEFAULT_NDI_ENABLED, 111 | ndiAlpha: DEFAULT_NDI_ALPHA, 112 | oscEnabled: DEFAULT_OSC_ENABLED, 113 | oscPort: DEFAULT_OSC_PORT, 114 | } 115 | 116 | export const DEFAULT_STORE: CountdownConfiguration = { 117 | defaults: { 118 | settings: { 119 | presets: DEFAULT_PRESETS, 120 | remote: DEFAULT_REMOTE_SETTINGS, 121 | setWindowAlwaysOnTop: DEFAULT_SET_WINDOW_ALWAYS_ON_TOP, 122 | closeAction: DEFAULT_CLOSE_ACTION, 123 | startHidden: DEFAULT_START_HIDDEN, 124 | timers: { [ulid()]: DEFAULT_TIMER_SETTINGS}, 125 | }, 126 | version: CURRENT_CONFIG_VERSION 127 | } 128 | } 129 | 130 | export interface ShowSections { 131 | timer: boolean 132 | progress: boolean 133 | clock: boolean 134 | secondsOnClock: boolean 135 | hours: boolean 136 | } 137 | 138 | export interface WindowBounds { 139 | alwaysOnTop: boolean 140 | fullscreenOn: number, 141 | x: number, 142 | y: number, 143 | width: number, 144 | height: number 145 | } 146 | 147 | export interface WindowColors { 148 | background: string 149 | resetBackground: string 150 | text: string 151 | timerFinishedText: string 152 | clock: string 153 | clockText: string 154 | } 155 | 156 | export interface WindowSettings { 157 | bounds: WindowBounds 158 | show: ShowSections 159 | messageBoxFixedHeight: boolean 160 | contentAtReset: ContentAtReset 161 | colors: WindowColors 162 | pulseAtZero: boolean 163 | use12HourClock: boolean 164 | } 165 | 166 | export interface Windows { 167 | [key: string]: WindowSettings; 168 | } 169 | 170 | export interface TimerSettings { 171 | name: string 172 | yellowAtOption: string 173 | yellowAtMinutes: number 174 | yellowAtPercent: number 175 | timerDuration: number 176 | setTimeLive: boolean 177 | stopTimerAtZero: boolean 178 | audioFile: string 179 | windows: Windows 180 | } 181 | 182 | export interface RemoteSettings { 183 | webServerEnabled: boolean 184 | webServerPort: number 185 | ndiEnabled: boolean 186 | ndiAlpha: boolean 187 | oscEnabled: boolean 188 | oscPort: number 189 | } 190 | 191 | export interface Timers { 192 | [key: string]: TimerSettings; 193 | } 194 | 195 | export interface CountdownSettings { 196 | presets: number[] 197 | remote: RemoteSettings 198 | setWindowAlwaysOnTop: boolean 199 | closeAction: CloseAction, 200 | startHidden: boolean, 201 | timers: Timers 202 | } 203 | 204 | export interface CountdownStore { 205 | version?: number 206 | settings: CountdownSettings, 207 | } 208 | 209 | export interface CountdownConfiguration { 210 | defaults: CountdownStore 211 | } 212 | -------------------------------------------------------------------------------- /src/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/icons/icon.icns -------------------------------------------------------------------------------- /src/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/icons/icon.ico -------------------------------------------------------------------------------- /src/icons/tray/TrayTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/icons/tray/TrayTemplate.png -------------------------------------------------------------------------------- /src/icons/tray/TrayTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/icons/tray/TrayTemplate@2x.png -------------------------------------------------------------------------------- /src/main/App.ts: -------------------------------------------------------------------------------- 1 | import NDI from "./Remotes/NDI.ts"; 2 | import addDefaultEvents from "./Utilities/addDefaultEvents.ts"; 3 | import addIpcHandles from "./Utilities/addIpcHandles.ts"; 4 | import {enableDevMode, isDev} from "./Utilities/dev.ts"; 5 | import BrowserWinHandler from "./Utilities/BrowserWinHandler.ts"; 6 | import createMainWindow from "./mainWindow.ts"; 7 | import setMenu from "./Utilities/setMenu.js"; 8 | import {app, BrowserWindow, screen, Tray, Menu, nativeImage, dialog, nativeTheme} from "electron"; 9 | import { 10 | CloseAction, 11 | DEFAULT_CLOSE_ACTION, 12 | DEFAULT_NDI_ENABLED, 13 | DEFAULT_OSC_ENABLED, 14 | DEFAULT_OSC_PORT, 15 | DEFAULT_WEBSERVER_ENABLED, 16 | DEFAULT_WEBSERVER_PORT, 17 | } from "../common/config.ts"; 18 | import HTTP from "./Remotes/HTTP.ts"; 19 | import {OSC} from "./Remotes/OSC.ts"; 20 | import {IpcTimerController} from "./Remotes/IpcTimerController.ts"; 21 | import macosTrayIcon from "../icons/tray/TrayTemplate.png" 22 | import macOsTrayIcon2x from "../icons/tray/TrayTemplate@2x.png" 23 | import otherOsTrayIcon from "../icons/icon.ico" 24 | import path from "path"; 25 | import * as process from "node:process"; 26 | import {Config} from "./Utilities/Config.ts"; 27 | import {TimersOrchestrator} from "./Utilities/TimersOrchestrator.ts"; 28 | 29 | // To be packaged, otherwise it doesn't work 30 | console.log(macOsTrayIcon2x) 31 | 32 | export class CountdownApp { 33 | mainWindowHandler: BrowserWinHandler = null 34 | countdownWindowHandler: BrowserWinHandler = null 35 | ipcTimerController: IpcTimerController; 36 | config: Config = new Config(this._configUpdated.bind(this)); 37 | timersOrchestrator: TimersOrchestrator 38 | 39 | ndiServer = new NDI("Countdown"); 40 | ndiTimer: NodeJS.Timeout = null; 41 | webServer: HTTP = null; 42 | oscServer: OSC = null; 43 | 44 | constructor() { 45 | addDefaultEvents(); 46 | addIpcHandles(this); 47 | if (isDev) { 48 | enableDevMode(); 49 | } 50 | 51 | this.timersOrchestrator = new TimersOrchestrator(this) 52 | this.ipcTimerController = new IpcTimerController(this.timersOrchestrator); 53 | } 54 | 55 | async run() { 56 | nativeTheme.themeSource = 'dark' 57 | 58 | this.mainWindowHandler = createMainWindow({ 59 | show: !this.config.settings.startHidden 60 | }); 61 | 62 | this.mainWindowHandler.onCreated((browserWindow) => { 63 | 64 | let appIcon: Tray 65 | if (process.platform === 'darwin') { 66 | const image = nativeImage.createFromPath(path.resolve( 67 | __dirname, 68 | macosTrayIcon, 69 | )); 70 | // Marks the image as a template image. 71 | image.setTemplateImage(true); 72 | 73 | appIcon = new Tray(image) 74 | } else { 75 | appIcon = new Tray(path.resolve( 76 | __dirname, 77 | otherOsTrayIcon, 78 | )) 79 | } 80 | 81 | appIcon.on('right-click', () => { 82 | const contextMenu = Menu.buildFromTemplate([ 83 | {label: 'Quit', role: 'quit'} 84 | ]) 85 | 86 | appIcon.popUpContextMenu(contextMenu); 87 | }) 88 | appIcon.on('click', () => { 89 | if (!this.mainWindowHandler.browserWindow.isVisible()) { 90 | this.mainWindowHandler.browserWindow.show() 91 | } else { 92 | this.mainWindowHandler.browserWindow.focus() 93 | } 94 | }) 95 | 96 | setMenu(this.mainWindowHandler, this.timersOrchestrator); 97 | 98 | const screensUpdated = (browserWindow: BrowserWindow) => { 99 | browserWindow.webContents.send('screens-updated'); 100 | } 101 | 102 | screen.on('display-added', () => screensUpdated(browserWindow)) 103 | screen.on('display-removed', () => screensUpdated(browserWindow)) 104 | screen.on('display-metrics-changed', () => screensUpdated(browserWindow)) 105 | 106 | app.on('before-quit', () => { 107 | browserWindow.destroy() 108 | }) 109 | 110 | browserWindow.on('close', async (event) => { 111 | event.preventDefault() 112 | 113 | switch (this.config.settings.closeAction ?? DEFAULT_CLOSE_ACTION) { 114 | case CloseAction.Ask: { 115 | const result = await dialog.showMessageBox({ 116 | message: "Choose an action", 117 | checkboxLabel: "Don't ask again", 118 | buttons: [ 119 | "Cancel", 120 | "Hide", 121 | "Quit" 122 | ] 123 | }) 124 | 125 | switch (result.response) { 126 | case 1: 127 | if (result.checkboxChecked) { 128 | this.config.set('closeAction', CloseAction.Hide) 129 | } 130 | browserWindow.hide() 131 | break; 132 | case 2: 133 | if (result.checkboxChecked) { 134 | this.config.set('closeAction', CloseAction.Close) 135 | } 136 | app.quit() 137 | break; 138 | } 139 | break; 140 | } 141 | case CloseAction.Hide: 142 | browserWindow.hide() 143 | break; 144 | case CloseAction.Close: 145 | app.quit() 146 | break; 147 | } 148 | }) 149 | }) 150 | 151 | const webServerEnabled = this.config.settings.remote.webServerEnabled ?? DEFAULT_WEBSERVER_ENABLED 152 | const port = this.config.settings.remote.webServerPort ?? DEFAULT_WEBSERVER_PORT 153 | 154 | this.mainWindowHandler.onCreated((browserWindow) => { 155 | this.webServer = new HTTP(this.timersOrchestrator, browserWindow); 156 | this.webServer.port = port; 157 | 158 | if (webServerEnabled) { 159 | this.webServer.start(); 160 | } 161 | 162 | const oscEnabled = this.config.settings.remote.oscEnabled ?? DEFAULT_OSC_ENABLED 163 | const oscPort = this.config.settings.remote.oscPort ?? DEFAULT_OSC_PORT 164 | this.oscServer = new OSC(oscPort, this.timersOrchestrator); 165 | if (oscEnabled) { 166 | this.oscServer.start(); 167 | } 168 | }) 169 | 170 | if (this.config.settings.remote.ndiEnabled ?? DEFAULT_NDI_ENABLED) { 171 | this.ndiServer.alpha = this.config.settings.remote.ndiAlpha; 172 | await this.ndiServer.start(); 173 | this.startNdiTimer(); 174 | } 175 | } 176 | 177 | startNdiTimer() { 178 | if (this.ndiTimer) return; 179 | this.ndiTimer = setInterval(this.ndiIntervalCallback.bind(this), 100); 180 | } 181 | 182 | stopNdiTimer() { 183 | if (!this.ndiTimer) return; 184 | clearInterval(this.ndiTimer); 185 | this.ndiTimer = null; 186 | } 187 | 188 | async ndiIntervalCallback() { 189 | this.timersOrchestrator.sendNDIFrames() 190 | } 191 | 192 | _configUpdated() { 193 | this.timersOrchestrator.configUpdated() 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/main/Migrations/BaseMigration.ts: -------------------------------------------------------------------------------- 1 | export interface BaseMigration { 2 | migrate(oldConfig: {[key: string]: unknown}): {[key: string]: unknown}; 3 | } 4 | -------------------------------------------------------------------------------- /src/main/Migrations/MergeOpacityToBackgroundColor.ts: -------------------------------------------------------------------------------- 1 | import {BaseMigration} from "./BaseMigration.ts"; 2 | 3 | export class MergeOpacityToBackgroundColor implements BaseMigration { 4 | migrate(oldConfig: { [key: string]: unknown }): { [key: string]: unknown } { 5 | const settings = oldConfig.settings as {[key: string]: unknown}; 6 | 7 | if (oldConfig.version) return oldConfig; 8 | 9 | if (!settings.backgroundColorOpacity) return oldConfig; 10 | if (!settings.backgroundColor) return oldConfig; 11 | if ((settings.backgroundColor as string).length > 8) return oldConfig; 12 | 13 | settings.backgroundColor = settings.backgroundColor + parseInt(settings.backgroundColorOpacity as string).toString(16).padStart(2, "0"); 14 | delete settings.backgroundColorOpacity; 15 | 16 | return { 17 | ...oldConfig, 18 | settings, 19 | }; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/Migrations/MoveBlackAtResetToContentAtReset.ts: -------------------------------------------------------------------------------- 1 | import {BaseMigration} from "./BaseMigration.ts"; 2 | import {ContentAtReset} from "../../common/config.ts"; 3 | 4 | export class MoveBlackAtResetToContentAtReset implements BaseMigration { 5 | migrate(oldConfig: { [key: string]: unknown }): { [key: string]: unknown } { 6 | const settings = oldConfig.settings as {[key: string]: unknown}; 7 | 8 | if (oldConfig.version) return oldConfig; 9 | 10 | if(!('blackAtReset' in settings)) return oldConfig; 11 | 12 | if (settings.blackAtReset) settings.contentAtReset = ContentAtReset.Empty; 13 | delete settings.blackAtReset; 14 | 15 | return { 16 | ...oldConfig, 17 | settings, 18 | }; 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/Migrations/MoveSettingsToWindow.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-nocheck 3 | import {BaseMigration} from "./BaseMigration.ts"; 4 | import {CloseAction, DEFAULT_TIMER_NAME} from '../../common/config.ts' 5 | import {ulid} from 'ulid' 6 | 7 | export class MoveSettingsToWindow implements BaseMigration { 8 | migrate(oldConfig: { [key: string]: unknown }): { [key: string]: unknown } { 9 | const settings = oldConfig.settings as {[key: string]: unknown}; 10 | 11 | if (oldConfig.version as number >= 2 && oldConfig.settings.timers) return oldConfig 12 | 13 | return { 14 | version: 2, 15 | settings: { 16 | presets: settings.presets, 17 | remote: { 18 | webServerEnabled: settings.webServerEnabled, 19 | webServerPort: settings.webServerPort, 20 | ndiEnabled: settings.ndiEnabled, 21 | ndiAlpha: settings.ndiAlpha, 22 | oscEnabled: settings.oscEnabled, 23 | oscPort: settings.oscPort, 24 | }, 25 | setWindowAlwaysOnTop: settings.setWindowAlwaysOnTop, 26 | closeAction: settings.closeAction ?? CloseAction.Ask, 27 | startHidden: settings.startHidden ?? false, 28 | timers: { 29 | [ulid()]: { 30 | name: DEFAULT_TIMER_NAME, 31 | yellowAtOption: settings.yellowAtOption, 32 | yellowAtMinutes: settings.yellowAtMinutes, 33 | yellowAtPercent: settings.yellowAtPercent, 34 | timerDuration: settings.timerDuration, 35 | setTimeLive: settings.setTimeLive, 36 | stopTimerAtZero: settings.stopTimerAtZero, 37 | windows: { 38 | [ulid()]: { 39 | bounds: { 40 | ...oldConfig.window, 41 | alwaysOnTop: settings.timerAlwaysOnTop, 42 | }, 43 | show: { 44 | ...(settings.show), 45 | hours: settings.showHours, 46 | }, 47 | messageBoxFixedHeight: settings.messageBoxFixedHeight, 48 | contentAtReset: settings.contentAtReset, 49 | colors: { 50 | background: settings.backgroundColor, 51 | resetBackground: settings.resetBackgroundColor, 52 | text: settings.textColor, 53 | timerFinishedText: settings.timerFinishedTextColor, 54 | clock: settings.clockColor, 55 | clockText: settings.clockTextColor, 56 | }, 57 | pulseAtZero: settings.pulseAtZero, 58 | use12HourClock: settings.use12HourClock, 59 | } 60 | } 61 | } 62 | } 63 | } 64 | }; 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/main/Migrations/RemoveFont.ts: -------------------------------------------------------------------------------- 1 | import {BaseMigration} from "./BaseMigration.ts"; 2 | 3 | export class RemoveFont implements BaseMigration { 4 | migrate(oldConfig: { [key: string]: unknown }): { [key: string]: unknown } { 5 | const settings = oldConfig.settings as {[key: string]: unknown}; 6 | 7 | if (oldConfig.version) return oldConfig; 8 | 9 | delete settings.font; 10 | 11 | return { 12 | ...oldConfig, 13 | settings, 14 | }; 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/Migrations/applyMigrations.ts: -------------------------------------------------------------------------------- 1 | import {MergeOpacityToBackgroundColor} from "./MergeOpacityToBackgroundColor.ts"; 2 | import {BaseMigration} from "./BaseMigration.ts"; 3 | import {MoveBlackAtResetToContentAtReset} from "./MoveBlackAtResetToContentAtReset.ts"; 4 | import {RemoveFont} from "./RemoveFont.ts"; 5 | import {MoveSettingsToWindow} from "./MoveSettingsToWindow.ts"; 6 | 7 | const migrations: BaseMigration[] = [ 8 | new MergeOpacityToBackgroundColor, 9 | new MoveBlackAtResetToContentAtReset, 10 | new RemoveFont, 11 | new MoveSettingsToWindow, 12 | ]; 13 | 14 | export function applyMigrations(oldConfig: {[key: string]: unknown}): {[key: string]: unknown} { 15 | 16 | let config = oldConfig; 17 | 18 | migrations.forEach((migration) => { 19 | config = migration.migrate(config); 20 | }) 21 | 22 | return config; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/Remotes/HTTP.ts: -------------------------------------------------------------------------------- 1 | import fastify, {FastifyInstance, RequestGenericInterface} from 'fastify'; 2 | import FastifyWebSocket from '@fastify/websocket'; 3 | import {BrowserWindow, ipcMain} from "electron"; 4 | import {TimerEngineWebSocketUpdate, WebSocketUpdate} from '../../common/TimerInterfaces.ts' 5 | import {TimersOrchestrator} from "../Utilities/TimersOrchestrator.ts"; 6 | // @ts-ignore 7 | import {WebSocket} from "ws"; 8 | 9 | const secondsPerMinute = 60; 10 | const secondsPerHour = secondsPerMinute * 60; 11 | 12 | interface TimeRequest extends RequestGenericInterface { 13 | Params: { 14 | timerId: string, 15 | hours: string, 16 | minutes: string, 17 | seconds: string, 18 | }; 19 | } 20 | 21 | interface MessageRequest extends RequestGenericInterface { 22 | Params: { 23 | timerId: string, 24 | message: string, 25 | } 26 | } 27 | 28 | interface GenericRequest extends RequestGenericInterface { 29 | Params: { 30 | timerId: string, 31 | } 32 | } 33 | 34 | export default class HTTP { 35 | fastifyServer: FastifyInstance = null; 36 | timersOrchestrator: TimersOrchestrator = null; 37 | browserWindow: BrowserWindow = null; 38 | isRunning = false; 39 | port: number = null; 40 | 41 | lastError: unknown = null; 42 | 43 | constructor (timersOrchestrator: TimersOrchestrator, browserWindow: BrowserWindow) { 44 | this.timersOrchestrator = timersOrchestrator; 45 | this.browserWindow = browserWindow; 46 | this.reset() 47 | this.setupIpc(); 48 | } 49 | 50 | reset() { 51 | this.fastifyServer = fastify(); 52 | this.fastifyServer.register(FastifyWebSocket); 53 | 54 | this.setupRoutes() 55 | } 56 | 57 | setupRoutes() { 58 | 59 | this.fastifyServer.get('/', (req, res) => { 60 | res.send('Countdown') 61 | }) 62 | 63 | this.fastifyServer.get('/timer/:timerId/set/:hours/:minutes/:seconds', (req, res) => { 64 | const hours = parseInt(req.params.hours); 65 | const minutes = parseInt(req.params.minutes); 66 | const seconds = parseInt(req.params.seconds); 67 | 68 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 69 | return res.send(404) 70 | } 71 | 72 | this.timersOrchestrator.timers[req.params.timerId].engine.set(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 73 | res.send(200) 74 | }) 75 | this.fastifyServer.get('/timer/:timerId/start/:hours/:minutes/:seconds', (req, res) => { 76 | const hours = parseInt(req.params.hours); 77 | const minutes = parseInt(req.params.minutes); 78 | const seconds = parseInt(req.params.seconds); 79 | 80 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 81 | return res.send(404) 82 | } 83 | 84 | this.timersOrchestrator.timers[req.params.timerId].engine.set(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 85 | this.timersOrchestrator.timers[req.params.timerId].engine.start(); 86 | res.send(200) 87 | }) 88 | this.fastifyServer.get('/timer/:timerId/start', (req, res) => { 89 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 90 | return res.send(404) 91 | } 92 | this.timersOrchestrator.timers[req.params.timerId].engine.start(); 93 | res.send(200) 94 | }) 95 | this.fastifyServer.get('/timer/:timerId/toggle-pause', (req, res) => { 96 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 97 | return res.send(404) 98 | } 99 | this.timersOrchestrator.timers[req.params.timerId].engine.toggleTimer(); 100 | res.send(200) 101 | }) 102 | this.fastifyServer.get('/timer/:timerId/pause', (req, res) => { 103 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 104 | return res.send(404) 105 | } 106 | this.timersOrchestrator.timers[req.params.timerId].engine.pause() 107 | res.send(200) 108 | }) 109 | this.fastifyServer.get('/timer/:timerId/resume', (req, res) => { 110 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 111 | return res.send(404) 112 | } 113 | this.timersOrchestrator.timers[req.params.timerId].engine.resume(); 114 | res.send(200) 115 | }) 116 | this.fastifyServer.get('/timer/:timerId/reset', (req, res) => { 117 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 118 | return res.send(404) 119 | } 120 | this.timersOrchestrator.timers[req.params.timerId].engine.reset(); 121 | res.send(200) 122 | }) 123 | this.fastifyServer.get('/timer/:timerId/jog-set/:hours/:minutes/:seconds', (req, res) => { 124 | const hours = parseInt(req.params.hours); 125 | const minutes = parseInt(req.params.minutes); 126 | const seconds = parseInt(req.params.seconds); 127 | 128 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 129 | return res.send(404) 130 | } 131 | this.timersOrchestrator.timers[req.params.timerId].engine.jogSet(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 132 | res.send(200) 133 | }) 134 | this.fastifyServer.get('/timer/:timerId/jog-current/:hours/:minutes/:seconds', (req, res) => { 135 | const hours = parseInt(req.params.hours); 136 | const minutes = parseInt(req.params.minutes); 137 | const seconds = parseInt(req.params.seconds); 138 | 139 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 140 | return res.send(404) 141 | } 142 | this.timersOrchestrator.timers[req.params.timerId].engine.jogCurrent(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 143 | res.send(200) 144 | }) 145 | 146 | this.fastifyServer.get('/timer/:timerId/message', (req, res) => { 147 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 148 | return res.send(404) 149 | } 150 | this.timersOrchestrator.timers[req.params.timerId].engine.setMessage(""); 151 | res.send(200); 152 | }) 153 | this.fastifyServer.get('/timer/:timerId/message/:message', (req, res) => { 154 | if (!Object.keys(this.timersOrchestrator.timers).includes(req.params.timerId)) { 155 | return res.send(404) 156 | } 157 | this.timersOrchestrator.timers[req.params.timerId].engine.setMessage(req.params.message); 158 | res.send(200); 159 | }) 160 | this.fastifyServer.get('/timers', (req, res) => { 161 | res 162 | .code(200) 163 | .header('Content-Type', 'application/json; charset=utf-8') 164 | .send(this.timersOrchestrator.app.config.settings.timers); 165 | }) 166 | 167 | /* Legacy */ 168 | this.fastifyServer.get('/set/:hours/:minutes/:seconds', (req, res) => { 169 | const hours = parseInt(req.params.hours); 170 | const minutes = parseInt(req.params.minutes); 171 | const seconds = parseInt(req.params.seconds); 172 | 173 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 174 | this.timersOrchestrator.timers[firstTimer].engine.set(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 175 | res.send(200) 176 | }) 177 | this.fastifyServer.get('/start/:hours/:minutes/:seconds', (req, res) => { 178 | const hours = parseInt(req.params.hours); 179 | const minutes = parseInt(req.params.minutes); 180 | const seconds = parseInt(req.params.seconds); 181 | 182 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 183 | 184 | this.timersOrchestrator.timers[firstTimer].engine.set(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 185 | this.timersOrchestrator.timers[firstTimer].engine.start(); 186 | res.send(200) 187 | }) 188 | this.fastifyServer.get('/start', (req, res) => { 189 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 190 | this.timersOrchestrator.timers[firstTimer].engine.start(); 191 | res.send(200) 192 | }) 193 | this.fastifyServer.get('/toggle-pause', (req, res) => { 194 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 195 | this.timersOrchestrator.timers[firstTimer].engine.toggleTimer(); 196 | res.send(200) 197 | }) 198 | this.fastifyServer.get('/pause', (req, res) => { 199 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 200 | this.timersOrchestrator.timers[firstTimer].engine.pause() 201 | res.send(200) 202 | }) 203 | this.fastifyServer.get('/resume', (req, res) => { 204 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 205 | this.timersOrchestrator.timers[firstTimer].engine.resume(); 206 | res.send(200) 207 | }) 208 | this.fastifyServer.get('/reset', (req, res) => { 209 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 210 | this.timersOrchestrator.timers[firstTimer].engine.reset(); 211 | res.send(200) 212 | }) 213 | this.fastifyServer.get('/jog-set/:hours/:minutes/:seconds', (req, res) => { 214 | const hours = parseInt(req.params.hours); 215 | const minutes = parseInt(req.params.minutes); 216 | const seconds = parseInt(req.params.seconds); 217 | 218 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 219 | this.timersOrchestrator.timers[firstTimer].engine.jogSet(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 220 | res.send(200) 221 | }) 222 | this.fastifyServer.get('/jog-current/:hours/:minutes/:seconds', (req, res) => { 223 | const hours = parseInt(req.params.hours); 224 | const minutes = parseInt(req.params.minutes); 225 | const seconds = parseInt(req.params.seconds); 226 | 227 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 228 | this.timersOrchestrator.timers[firstTimer].engine.jogCurrent(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 229 | res.send(200) 230 | }) 231 | 232 | this.fastifyServer.get('/message', (req, res) => { 233 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 234 | this.timersOrchestrator.timers[firstTimer].engine.setMessage(""); 235 | res.send(200); 236 | }) 237 | this.fastifyServer.get('/message/:message', (req, res) => { 238 | const firstTimer = Object.keys(this.timersOrchestrator.timers).sort()[0] 239 | this.timersOrchestrator.timers[firstTimer].engine.setMessage(req.params.message); 240 | res.send(200); 241 | }) 242 | 243 | this.fastifyServer.register(async function (fastify) { 244 | fastify.get('/ws', { websocket: true }, () => {}); 245 | }) 246 | 247 | } 248 | 249 | sendToWebSocket(update: WebSocketUpdate): void { 250 | if (!this.fastifyServer.websocketServer) { 251 | return; 252 | } 253 | this.fastifyServer.websocketServer.clients.forEach(function each(client: WebSocket) { 254 | if (client.readyState === 1) { 255 | client.send(JSON.stringify(update)) 256 | } 257 | }) 258 | } 259 | 260 | setupIpc(): void { 261 | 262 | ipcMain.handle('server-running', () => { 263 | return this.buildStatusUpdateContent() 264 | }) 265 | 266 | ipcMain.handle('webserver-manager', async (event, command, port) => { 267 | switch (command) { 268 | case 'stop': 269 | await this.stop() 270 | return false 271 | case 'start': { 272 | this.port = parseInt(port) ?? 6565 273 | return await this.start() 274 | } 275 | } 276 | }) 277 | } 278 | 279 | async start () { 280 | const promise = new Promise((resolve) => { 281 | this.fastifyServer.listen({ port: this.port, host: '0.0.0.0' }, err => { 282 | if (err) { 283 | this.isRunning = false 284 | this.lastError = err.message 285 | this.sendIpcStatusUpdate() 286 | resolve(false) 287 | return 288 | } 289 | this.isRunning = true 290 | this.lastError = null 291 | resolve(true); 292 | this.sendIpcStatusUpdate() 293 | }) 294 | }) 295 | 296 | return await promise; 297 | } 298 | 299 | async stop () { 300 | const promise = new Promise((resolve) => { 301 | this.fastifyServer.close(() => { 302 | this.reset(); 303 | this.isRunning = false 304 | this.sendIpcStatusUpdate() 305 | resolve(true) 306 | }) 307 | }) 308 | 309 | return await promise 310 | } 311 | 312 | sendIpcStatusUpdate() { 313 | this.browserWindow.webContents.send('webserver-update', this.buildStatusUpdateContent()) 314 | } 315 | 316 | buildStatusUpdateContent() { 317 | return { 318 | port: this.port, 319 | isRunning: this.isRunning, 320 | lastError: this.lastError, 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/main/Remotes/IpcTimerController.ts: -------------------------------------------------------------------------------- 1 | import {ipcMain} from "electron"; 2 | import {IpcTimerCommand, IpcTimerCommandName} from "../../common/IpcInterfaces.ts"; 3 | import IpcMainInvokeEvent = Electron.IpcMainInvokeEvent; 4 | import {TimersOrchestrator} from "../Utilities/TimersOrchestrator.ts"; 5 | 6 | 7 | export class IpcTimerController { 8 | timersOrchestrator: TimersOrchestrator; 9 | 10 | constructor(timersOrchestrator: TimersOrchestrator) { 11 | this.timersOrchestrator = timersOrchestrator; 12 | ipcMain.handle('command', this._invokeCommand.bind(this)); 13 | } 14 | 15 | private _invokeCommand(event: IpcMainInvokeEvent, command: IpcTimerCommand) { 16 | const timerEngine = this.timersOrchestrator.timers[command.timerId].engine 17 | 18 | switch (command.name) { 19 | case IpcTimerCommandName.SetSeconds: 20 | timerEngine.set(command.seconds); 21 | break; 22 | case IpcTimerCommandName.Start: 23 | timerEngine.start(); 24 | break; 25 | case IpcTimerCommandName.Reset: 26 | timerEngine.reset(); 27 | break; 28 | case IpcTimerCommandName.TogglePause: 29 | timerEngine.toggleTimer(); 30 | break; 31 | case IpcTimerCommandName.Pause: 32 | if (!timerEngine.timerIsRunning) return; 33 | timerEngine.toggleTimer(); 34 | break; 35 | case IpcTimerCommandName.Resume: 36 | if (timerEngine.timerIsRunning) return; 37 | timerEngine.toggleTimer(); 38 | break; 39 | case IpcTimerCommandName.JogSet: 40 | timerEngine.jogSet(command.seconds); 41 | break; 42 | case IpcTimerCommandName.JogCurrent: 43 | timerEngine.jogCurrent(command.seconds); 44 | break; 45 | case IpcTimerCommandName.Message: 46 | timerEngine.setMessage(command.message); 47 | break; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/Remotes/NDI.ts: -------------------------------------------------------------------------------- 1 | import grandiose, {FourCC, FrameType, VideoFrame} from "grandiose"; 2 | import * as process from "process"; 3 | import os from "os"; 4 | import ImageBufferAdjustment from "../Utilities/ImageBufferAdjustment.js"; 5 | import NativeImage = Electron.NativeImage; 6 | 7 | export default class NDIManager { 8 | name = ""; 9 | ndiSender: grandiose.Sender = null; 10 | timeStart: bigint = null; 11 | alpha = false; 12 | 13 | constructor(name: string) { 14 | this.name = name; 15 | } 16 | 17 | async start() { 18 | if (this.ndiSender) return; 19 | grandiose.initialize(); 20 | this.ndiSender = await grandiose.send({ 21 | name: this.name, 22 | clockVideo: false, 23 | clockAudio: false 24 | }) 25 | this.timeStart = (BigInt(Date.now()) * BigInt(1e6) - process.hrtime.bigint()) 26 | } 27 | 28 | async stop() { 29 | if (!this.ndiSender) return; 30 | await this.ndiSender.destroy(); 31 | this.ndiSender = null; 32 | grandiose.destroy(); 33 | } 34 | 35 | _timeNow(): bigint { 36 | return this.timeStart + process.hrtime.bigint(); 37 | } 38 | 39 | /** 40 | * @param {electron.NativeImage} image 41 | */ 42 | async sendFrame(image: NativeImage) { 43 | const size = image.getSize(); 44 | const buffer = image.getBitmap(); 45 | /* 46 | convert from ARGB (Electron/Chromium on big endian CPU) 47 | to BGRA (supported input of NDI SDK). On little endian 48 | CPU the input is already BGRA. 49 | */ 50 | if (os.endianness() === "BE") { 51 | ImageBufferAdjustment.ARGBtoBGRA(buffer) 52 | } 53 | 54 | /* optionally convert from BGRA to BGRX (no alpha channel) */ 55 | let fourCC = FourCC.BGRA; 56 | if (!this.alpha) { // no alpha 57 | ImageBufferAdjustment.BGRAtoBGRX(buffer) 58 | fourCC = FourCC.BGRX; 59 | } 60 | 61 | /* send NDI video frame */ 62 | const now = this._timeNow() 63 | const bytesForBGRA = 4 64 | const frame: VideoFrame = { 65 | type: 'video', 66 | /* base information */ 67 | timecode: now / BigInt(100), 68 | 69 | /* type-specific information */ 70 | xres: size.width, 71 | yres: size.height, 72 | frameRateN: 30 * 1000, 73 | frameRateD: 1000, 74 | pictureAspectRatio: size.width / size.height, 75 | frameFormatType: FrameType.Progressive, 76 | lineStrideBytes: size.width * bytesForBGRA, 77 | 78 | /* the data itself */ 79 | fourCC, 80 | data: buffer 81 | } 82 | 83 | await this.ndiSender.video(frame) 84 | } 85 | 86 | hasConnections(): boolean { 87 | return this.ndiSender.connections() !== 0 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/Remotes/OSC.ts: -------------------------------------------------------------------------------- 1 | import {ArgumentType, Server} from "node-osc"; 2 | import {TimersOrchestrator} from "../Utilities/TimersOrchestrator.ts"; 3 | 4 | const secondsPerMinute = 60; 5 | const secondsPerHour = secondsPerMinute * 60; 6 | 7 | export class OSC { 8 | isRunning = false; 9 | oscServer: Server = null; 10 | port: number = null; 11 | timersOrchestrator: TimersOrchestrator = null 12 | constructor(port: number, timersOrchestrator: TimersOrchestrator) { 13 | this.port = port; 14 | this.timersOrchestrator = timersOrchestrator; 15 | } 16 | 17 | start() { 18 | if (this.isRunning) return; 19 | this.oscServer = new Server(this.port, '0.0.0.0', () => { 20 | this.isRunning = true; 21 | }) 22 | 23 | this.oscServer.on('message', this._messageReceived.bind(this)); 24 | } 25 | 26 | stop() { 27 | if (!this.isRunning) return; 28 | this.oscServer.close(); 29 | this.isRunning = false; 30 | } 31 | 32 | _messageReceived(message: [string, ...ArgumentType[]]) { 33 | console.log(message) 34 | const [messageType, ...args] = message; 35 | const timerId = args[0] as string 36 | const hours = +args[1] 37 | const minutes = +args[2] 38 | const seconds = +args[3] 39 | 40 | switch (messageType) { 41 | case '/start': 42 | if (args.length === 3) { 43 | this.timersOrchestrator.timers[timerId].engine.set(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 44 | this.timersOrchestrator.timers[timerId].engine.start(); 45 | } else { 46 | this.timersOrchestrator.timers[timerId].engine.start(); 47 | } 48 | break; 49 | case '/set': 50 | this.timersOrchestrator.timers[timerId].engine.set(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 51 | break; 52 | case '/toggle-pause': 53 | this.timersOrchestrator.timers[timerId].engine.toggleTimer(); 54 | break; 55 | case '/pause': 56 | this.timersOrchestrator.timers[timerId].engine.pause(); 57 | break; 58 | case '/resume': 59 | this.timersOrchestrator.timers[timerId].engine.resume(); 60 | break; 61 | case '/reset': 62 | this.timersOrchestrator.timers[timerId].engine.reset(); 63 | break; 64 | case '/jog-set': 65 | this.timersOrchestrator.timers[timerId].engine.jogSet(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 66 | break; 67 | case '/jog-current': 68 | this.timersOrchestrator.timers[timerId].engine.jogCurrent(hours * secondsPerHour + minutes * secondsPerMinute + seconds); 69 | break; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/TimerEngine.ts: -------------------------------------------------------------------------------- 1 | import {Timer} from "./Utilities/Timer.ts"; 2 | import dayjs from "dayjs"; 3 | import duration from "dayjs/plugin/duration.js"; 4 | import { 5 | MessageUpdateCallback, PlaySoundCallback, 6 | UpdateCallback, 7 | WebSocketUpdateCallback 8 | } from "../common/TimerInterfaces.ts"; 9 | import {DEFAULT_SET_TIME_LIVE, DEFAULT_STOP_TIMER_AT_ZERO, DEFAULT_YELLOW_AT_OPTION} from "../common/config.ts"; 10 | dayjs.extend(duration); 11 | 12 | export interface TimerEngineOptions { 13 | yellowAt?: number 14 | yellowAtOption?: string 15 | setTimeLive?: boolean 16 | stopTimerAtZero?: boolean 17 | audioFile?: string 18 | } 19 | 20 | export class TimerEngine { 21 | private _currentSeconds = 0; 22 | private _secondsSetOnCurrentTimer = 0; 23 | private _audioRun = false; 24 | private _timer: Timer; 25 | private _currentInterval = 1000; 26 | 27 | options: TimerEngineOptions = { 28 | stopTimerAtZero: DEFAULT_STOP_TIMER_AT_ZERO, 29 | yellowAt: 0, 30 | yellowAtOption: DEFAULT_YELLOW_AT_OPTION, 31 | setTimeLive: DEFAULT_SET_TIME_LIVE, 32 | audioFile: null, 33 | } 34 | totalSeconds = 0; 35 | timerIsRunning = false; 36 | audioEnabled = true; 37 | update: UpdateCallback = null; 38 | webSocketUpdate: WebSocketUpdateCallback = null; 39 | messageUpdate: MessageUpdateCallback = null; 40 | playSound: PlaySoundCallback = null; 41 | 42 | constructor(interval: number, options: TimerEngineOptions, update: UpdateCallback, webSocketUpdate: WebSocketUpdateCallback, messageUpdate: MessageUpdateCallback, playSound: PlaySoundCallback) { 43 | this._timer = new Timer(interval, this._timerTick.bind(this), this._timerStatusChanged.bind(this)) 44 | this.options = { 45 | ...this.options, 46 | ...options, 47 | } 48 | 49 | this.update = update; 50 | this.webSocketUpdate = webSocketUpdate; 51 | this.messageUpdate = messageUpdate; 52 | this.playSound = playSound; 53 | } 54 | 55 | extraSeconds() { 56 | if (this._currentSeconds > 0) { 57 | return 0; 58 | } 59 | 60 | return Math.abs(this._currentSeconds); 61 | } 62 | 63 | countSeconds() { 64 | if (this._currentSeconds < 0) { 65 | return 0; 66 | } 67 | 68 | return this._currentSeconds; 69 | } 70 | 71 | isReset() { 72 | return this._timer.secondsSet === 0 && this._currentSeconds === 0; 73 | } 74 | 75 | isCountingUp() { 76 | return this._currentSeconds <= 0; 77 | } 78 | 79 | isExpiring() { 80 | if (this.isReset() || this.isCountingUp()) { 81 | return false; 82 | } 83 | 84 | if (this.options.yellowAtOption === 'minutes' 85 | && this.options.yellowAt >= this._currentSeconds / 60) { 86 | return true; 87 | } 88 | 89 | if (this.options.yellowAtOption === 'percent' 90 | && this.options.yellowAt >= this._currentSeconds * 100 / this._timer.secondsSet) { 91 | return true; 92 | } 93 | 94 | return false; 95 | } 96 | 97 | endsAt() { 98 | if (this.countSeconds() <= 0) return null; 99 | return dayjs().add(this._currentSeconds / 1000 * this._currentInterval, 's').format('HH:mm'); 100 | } 101 | 102 | setTimerInterval(interval: number) { 103 | this._currentInterval = interval; 104 | this._timer.setInterval(interval); 105 | this._timer.adjustingTimer.interval = interval; 106 | } 107 | 108 | start() { 109 | this._secondsSetOnCurrentTimer = this.totalSeconds; 110 | this._audioRun = false; 111 | this._timer.start(this.totalSeconds, this.options.stopTimerAtZero); 112 | this._sendUpdate(); 113 | this._sendWebSocketUpdate(); 114 | } 115 | 116 | startResumePause() { 117 | if (this.timerIsRunning) { 118 | this._timer.pause(); 119 | return; 120 | } 121 | 122 | if (!this.isReset() && !this.timerIsRunning) { 123 | this._timer.resume(); 124 | return; 125 | } 126 | 127 | this.start(); 128 | } 129 | 130 | toggleTimer() { 131 | this._timer.toggle(); 132 | this._sendWebSocketUpdate(); 133 | this._sendUpdate(); 134 | } 135 | 136 | pause() { 137 | this._timer.pause(); 138 | this._sendWebSocketUpdate(); 139 | this._sendUpdate(); 140 | } 141 | 142 | resume() { 143 | this._timer.resume(); 144 | this._sendWebSocketUpdate(); 145 | this._sendUpdate(); 146 | } 147 | 148 | reset() { 149 | if (this._secondsSetOnCurrentTimer === 0) { 150 | this.totalSeconds = 0; 151 | } 152 | 153 | this._audioRun = true; 154 | this._timer.reset(); 155 | this._secondsSetOnCurrentTimer = 0; 156 | 157 | this._sendUpdate(); 158 | this._sendWebSocketUpdate(); 159 | } 160 | 161 | set(seconds: number) { 162 | this.totalSeconds = seconds; 163 | this._sendUpdate(); 164 | this._sendWebSocketUpdate(); 165 | } 166 | 167 | jogSet(seconds: number) { 168 | if (this.totalSeconds <= 0 && seconds <= 0) return; 169 | this.set(this.totalSeconds + seconds); 170 | } 171 | 172 | jogCurrent(seconds: number) { 173 | if (!this._timer.isRunning()) return; 174 | this._timer.add(seconds); 175 | this._sendUpdate(); 176 | } 177 | 178 | add(minutes: number) { 179 | if (this._timer.isRunning()) { 180 | this._timer.add(minutes * 60); 181 | } else { 182 | this.totalSeconds += minutes * 60; 183 | } 184 | 185 | this._sendUpdate(); 186 | } 187 | 188 | sub(minutes: number) { 189 | if (this._timer.isRunning()) { 190 | this._timer.sub(minutes * 60); 191 | } else { 192 | this.totalSeconds -= minutes * 60; 193 | } 194 | 195 | this._sendUpdate(); 196 | } 197 | 198 | setMessage(message?: string) { 199 | this.messageUpdate({ 200 | timerId: '', 201 | message, 202 | }); 203 | } 204 | 205 | _timerTick(seconds: number) { 206 | this._currentSeconds = seconds; 207 | this._sendUpdate(); 208 | this._sendWebSocketUpdate(); 209 | } 210 | 211 | private _sendUpdate() { 212 | let currentSeconds = this._currentSeconds; 213 | 214 | if (this.options.setTimeLive && this.isReset()) { 215 | currentSeconds = this.totalSeconds; 216 | } 217 | 218 | this.update({ 219 | setSeconds: this.totalSeconds, 220 | currentSeconds: currentSeconds, 221 | countSeconds: this.countSeconds(), 222 | extraSeconds: this.extraSeconds(), 223 | secondsSetOnCurrentTimer: this._secondsSetOnCurrentTimer, 224 | isExpiring: this.isExpiring(), 225 | isReset: this.isReset(), 226 | isRunning: this.timerIsRunning, 227 | isCountingUp: this.isCountingUp(), 228 | timerEndsAt: this.endsAt(), 229 | }) 230 | } 231 | 232 | private _sendWebSocketUpdate() { 233 | const isExpired = this._currentSeconds <= 0; 234 | 235 | let state = 'Running'; 236 | if (this.isReset()) { 237 | state = 'Not Running'; 238 | } else if (!this.timerIsRunning) { 239 | state = 'Paused'; 240 | } else if (isExpired) { 241 | if (this.audioEnabled && !this._audioRun) { 242 | this.playSound(this.options.audioFile) 243 | this._audioRun = true; 244 | } 245 | state = 'Expired'; 246 | } else if (this.isExpiring()) { 247 | state = 'Expiring'; 248 | } 249 | 250 | const setTimeDuration = dayjs.duration(Math.abs(this.totalSeconds), 'seconds'); 251 | const currentTimeDuration = dayjs.duration(Math.abs(this._currentSeconds), 'seconds'); 252 | const timeSetOnCurrentTimerDuration = dayjs.duration(this._timer.secondsSet, 'seconds'); 253 | 254 | this.webSocketUpdate({ 255 | 256 | state: state, 257 | setTime: this.totalSeconds, 258 | setTimeHms: setTimeDuration.format('HH:mm:ss'), 259 | setTimeMs: setTimeDuration.format('mm:ss'), 260 | setTimeH: setTimeDuration.format('HH'), 261 | setTimeM: setTimeDuration.format('mm'), 262 | setTimeS: setTimeDuration.format('ss'), 263 | currentTimeHms: currentTimeDuration.format('HH:mm:ss'), 264 | currentTimeMs: currentTimeDuration.format('mm:ss'), 265 | currentTimeH: currentTimeDuration.format('HH'), 266 | currentTimeM: currentTimeDuration.format('mm'), 267 | currentTimeS: currentTimeDuration.format('ss'), 268 | currentTime: this._currentSeconds, 269 | timeSetOnCurrentTimer: this._timer.secondsSet, 270 | timeSetOnCurrentTimerHms: timeSetOnCurrentTimerDuration.format('HH:mm:ss'), 271 | timeSetOnCurrentTimerMs: timeSetOnCurrentTimerDuration.format('mm:ss'), 272 | timeSetOnCurrentTimerH: timeSetOnCurrentTimerDuration.format('HH'), 273 | timeSetOnCurrentTimerM: timeSetOnCurrentTimerDuration.format('mm'), 274 | timeSetOnCurrentTimerS: timeSetOnCurrentTimerDuration.format('ss'), 275 | timerEndsAt: this.endsAt(), 276 | }) 277 | } 278 | 279 | _timerStatusChanged() { 280 | this.timerIsRunning = this._timer.isRunning(); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/main/Utilities/AdjustingInterval.ts: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/29971898/how-to-create-an-accurate-timer-in-javascript 2 | 3 | export default class AdjustingInterval { 4 | interval = 1000; 5 | callback: () => void = null; 6 | _timeout: NodeJS.Timeout = null; 7 | _expected = 0; 8 | 9 | constructor(callback: () => void, interval: number) { 10 | this.callback = callback; 11 | this.interval = interval; 12 | } 13 | 14 | start() { 15 | this._expected = Date.now() + this.interval; 16 | this._timeout = setTimeout(this.step.bind(this), this.interval); 17 | } 18 | 19 | stop() { 20 | clearTimeout(this._timeout); 21 | this._timeout = null; 22 | } 23 | 24 | isRunning() { 25 | return !!this._timeout; 26 | } 27 | 28 | step() { 29 | const drift = Date.now() - this._expected; 30 | if (drift > this.interval) { 31 | this.stop(); 32 | } 33 | this.callback(); 34 | this._expected += this.interval; 35 | this._timeout = setTimeout(this.step.bind(this), Math.max(0, this.interval - drift)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/Utilities/BrowserWinHandler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { EventEmitter } from 'events' 3 | import { BrowserWindow, app } from 'electron' 4 | import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions; 5 | const DEV_SERVER_URL = process.env.DEV_SERVER_URL 6 | const isProduction = process.env.NODE_ENV === 'production' 7 | const isDev = process.env.NODE_ENV === 'development' 8 | 9 | declare const MAIN_WINDOW_WEBPACK_ENTRY: string; 10 | 11 | export default class BrowserWinHandler { 12 | allowRecreate: boolean 13 | _eventEmitter: EventEmitter 14 | options: BrowserWindowConstructorOptions 15 | browserWindow: BrowserWindow 16 | 17 | /** 18 | * @param [options] {object} - browser window options 19 | * @param [allowRecreate] {boolean} 20 | */ 21 | constructor (options: BrowserWindowConstructorOptions, allowRecreate = true) { 22 | this._eventEmitter = new EventEmitter() 23 | this.allowRecreate = allowRecreate 24 | this.options = options 25 | this.browserWindow = null 26 | this._createInstance() 27 | } 28 | 29 | _createInstance () { 30 | // This method will be called when Electron has finished 31 | // initialization and is ready to create browser windows. 32 | // Some APIs can only be used after this event occurs. 33 | if (app.isReady()) this._create() 34 | else { 35 | app.once('ready', () => { 36 | this._create() 37 | }) 38 | } 39 | } 40 | 41 | _create () { 42 | this.browserWindow = new BrowserWindow({ 43 | ...this.options, 44 | webPreferences: { 45 | ...this.options.webPreferences, 46 | nodeIntegration: true, // allow loading modules via the require () function 47 | contextIsolation: false, // https://github.com/electron/electron/issues/18037#issuecomment-806320028 48 | backgroundThrottling: false, // countdown will not run when window is beh 49 | }, 50 | }) 51 | this.browserWindow.on('closed', () => { 52 | // Dereference the window object 53 | this.browserWindow = null 54 | }) 55 | this._eventEmitter.emit('created') 56 | } 57 | 58 | _recreate () { 59 | if (this.browserWindow === null) this._create() 60 | } 61 | 62 | /** 63 | * @callback onReadyCallback 64 | * @param {BrowserWindow} 65 | */ 66 | 67 | /**x 68 | * 69 | * @param callback {onReadyCallback} 70 | */ 71 | onCreated (callback: (browserWindow: BrowserWindow) => void) { 72 | if (this.browserWindow !== null) return callback(this.browserWindow); 73 | this._eventEmitter.once('created', () => { 74 | callback(this.browserWindow) 75 | }) 76 | } 77 | 78 | async loadPage(pagePath: string, query?: Record) { 79 | if (!this.browserWindow) return Promise.reject(new Error('The page could not be loaded before win \'created\' event')) 80 | const serverUrl = MAIN_WINDOW_WEBPACK_ENTRY; 81 | const urlSearchParams = new URLSearchParams(query); 82 | let queryString = urlSearchParams.toString() 83 | if (queryString !== "") { 84 | queryString = "?" + queryString 85 | } 86 | console.log(queryString) 87 | const fullPath = serverUrl + queryString + '#' + pagePath; 88 | await this.browserWindow.loadURL(fullPath) 89 | } 90 | 91 | /** 92 | * 93 | * @returns {Promise} 94 | */ 95 | created () { 96 | return new Promise(resolve => { 97 | this.onCreated(() => resolve(this.browserWindow)) 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/Utilities/Config.ts: -------------------------------------------------------------------------------- 1 | import {CountdownSettings, DEFAULT_STORE} from "../../common/config.ts"; 2 | // @ts-ignore 3 | import Store from "electron-store"; 4 | import {applyMigrations} from "../Migrations/applyMigrations.ts"; 5 | // @ts-ignore 6 | import {getProperty, setProperty} from "dot-prop"; 7 | 8 | 9 | export class Config { 10 | store = new Store(DEFAULT_STORE) 11 | updatedConfig: () => void = null 12 | 13 | private _settings: CountdownSettings 14 | 15 | public get settings(): CountdownSettings { 16 | return this._settings 17 | } 18 | 19 | constructor(updatedConfig: () => void) { 20 | this.updatedConfig = updatedConfig 21 | const newConfig = applyMigrations(this.store.get(null)) 22 | this.store.set(newConfig) 23 | 24 | this._settings = this.store.get('settings') 25 | } 26 | 27 | set(key: string, value: unknown) { 28 | if (key) { 29 | setProperty(this._settings, key, value) 30 | } else { 31 | this._settings = value as CountdownSettings 32 | } 33 | this.store.set('settings', this._settings) 34 | if (this.updatedConfig) { 35 | this.updatedConfig() 36 | } 37 | return this._settings 38 | } 39 | 40 | get(key?: string): unknown { 41 | if (!key) return this._settings 42 | return getProperty(this._settings, key) 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /src/main/Utilities/ImageBufferAdjustment.js: -------------------------------------------------------------------------------- 1 | /* helper class for adjusting image buffer byte orders */ 2 | export default class ImageBufferAdjustment { 3 | /* convert between ARGB and BGRA */ 4 | static ARGBtoBGRA (data) { 5 | for (let i = 0; i < data.length; i += 4) { 6 | const A = data[i] 7 | data[i] = data[i + 3] 8 | data[i + 3] = A 9 | const R = data[i + 1] 10 | data[i + 1] = data[i + 2] 11 | data[i + 2] = R 12 | } 13 | } 14 | 15 | /* convert from ARGB to RGBA */ 16 | static ARGBtoRGBA (data) { 17 | for (let i = 0; i < data.length; i += 4) { 18 | const A = data[i] 19 | data[i] = data[i + 1] 20 | data[i + 1] = data[i + 2] 21 | data[i + 2] = data[i + 3] 22 | data[i + 3] = A 23 | } 24 | } 25 | 26 | /* convert from BGRA to RGBA */ 27 | static BGRAtoRGBA (data) { 28 | for (let i = 0; i < data.length; i += 4) { 29 | const B = data[i] 30 | data[i] = data[i + 2] 31 | data[i + 2] = B 32 | } 33 | } 34 | 35 | /* convert from BGRA to BGRX */ 36 | static BGRAtoBGRX (data) { 37 | for (let i = 0; i < data.length; i += 4) 38 | data[i + 3] = 255 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/Utilities/Timer.ts: -------------------------------------------------------------------------------- 1 | import AdjustingInterval from "./AdjustingInterval.ts"; 2 | import {DEFAULT_TIMER_DURATION} from "../../common/config.ts"; 3 | 4 | export class Timer { 5 | interval: number; 6 | adjustingTimer: AdjustingInterval; 7 | secondsSet = 0; 8 | seconds = 0; 9 | stopsAtZero = false; 10 | timerTickCallback: (seconds: number) => void = null; 11 | timerStatusChangeCallback: (status: string) => void = null; 12 | 13 | constructor(interval: number = DEFAULT_TIMER_DURATION, timerTickCallback: (seconds: number) => void, timerStatusChangeCallback: (status: string) => void) { 14 | this.interval = interval; 15 | this.adjustingTimer = new AdjustingInterval(this._timerTick.bind(this), this.interval); 16 | this.timerTickCallback = timerTickCallback; 17 | this.timerStatusChangeCallback = timerStatusChangeCallback; 18 | } 19 | 20 | isRunning() { 21 | return this.adjustingTimer.isRunning(); 22 | } 23 | 24 | setInterval(interval: number) { 25 | this.interval = interval; 26 | this.adjustingTimer.interval = interval; 27 | } 28 | 29 | start(seconds: number, stopsAtZero: boolean) { 30 | if (this.isRunning()) { 31 | this.pause(); 32 | } 33 | 34 | this.secondsSet = seconds; 35 | this.seconds = seconds; 36 | this.stopsAtZero = stopsAtZero; 37 | 38 | this.timerTickCallback(this.seconds); 39 | 40 | this.resume(); 41 | } 42 | 43 | resume() { 44 | if (this.isRunning()) return; 45 | this.adjustingTimer.start(); 46 | this.timerStatusChangeCallback('started'); 47 | } 48 | 49 | pause() { 50 | if (!this.isRunning()) return; 51 | this.adjustingTimer.stop(); 52 | this.timerStatusChangeCallback('stopped'); 53 | } 54 | 55 | toggle() { 56 | if (this.isRunning()) { 57 | this.pause(); 58 | } else { 59 | this.resume(); 60 | } 61 | } 62 | 63 | reset() { 64 | this.pause(); 65 | this.seconds = 0; 66 | this.secondsSet = 0; 67 | this.timerStatusChangeCallback('reset'); 68 | this.timerTickCallback(0); 69 | } 70 | 71 | add(seconds: number) { 72 | this.seconds += seconds; 73 | this.timerTickCallback(this.seconds); 74 | } 75 | 76 | sub(seconds: number) { 77 | this.seconds -= seconds; 78 | this.timerTickCallback(this.seconds); 79 | } 80 | 81 | _timerTick() { 82 | this.seconds = this.seconds - 1; 83 | this.timerTickCallback(this.seconds); 84 | 85 | if (this.seconds <= 0 && this.stopsAtZero) { 86 | this.seconds = 0; 87 | this.timerTickCallback(0); 88 | this.pause(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/Utilities/TimersOrchestrator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TimerSettings, 3 | WindowBounds, 4 | WindowSettings 5 | } from "../../common/config.ts"; 6 | import {TimerEngine, TimerEngineOptions} from "../TimerEngine.ts"; 7 | import BrowserWinHandler from "./BrowserWinHandler.ts"; 8 | import createCountdownWindow from "../countdownWindow.ts"; 9 | import {BrowserWindow, screen} from "electron"; 10 | import {MessageUpdate, TimerEngineUpdate, TimerEngineWebSocketUpdate} from "../../common/TimerInterfaces.ts"; 11 | import {CountdownApp} from "../App.ts"; 12 | import {sleep} from "./utilities.ts"; 13 | import {promises as fs} from "node:fs"; 14 | // @ts-ignore 15 | import mime from "mime/lite"; 16 | import NDIManager from "../Remotes/NDI.ts"; 17 | 18 | interface WindowsKV { 19 | [key: string]: BrowserWinHandler; 20 | } 21 | 22 | interface NDIManagersKV { 23 | [key: string]: NDIManager; 24 | } 25 | 26 | interface SingleTimer { 27 | settings: TimerSettings 28 | engine: TimerEngine 29 | windows: WindowsKV 30 | ndiServers: NDIManagersKV 31 | } 32 | 33 | interface TimersKV { 34 | [key: string]: SingleTimer 35 | } 36 | 37 | export class TimersOrchestrator { 38 | app: CountdownApp 39 | timers: TimersKV = {} 40 | currentTimer: string|null = null 41 | 42 | constructor(app: CountdownApp) { 43 | this.app = app 44 | Object.keys(app.config.settings.timers).forEach(timerId => { 45 | this.createTimer(timerId, app.config.settings.timers[timerId]); 46 | }) 47 | } 48 | 49 | public createTimer(timerId: string, settings: TimerSettings) { 50 | const options: TimerEngineOptions = { 51 | yellowAtOption: settings.yellowAtOption, 52 | yellowAt: settings.yellowAtOption === 'minutes' ? settings.yellowAtMinutes : settings.yellowAtPercent, 53 | stopTimerAtZero: settings.stopTimerAtZero, 54 | setTimeLive: settings.setTimeLive, 55 | audioFile: settings.audioFile, 56 | } 57 | 58 | const timerEngine = new TimerEngine( 59 | settings.timerDuration, 60 | options, 61 | (update: TimerEngineUpdate) => { 62 | this._timerEngineUpdate(timerId, update) 63 | }, 64 | (update: TimerEngineWebSocketUpdate) => { 65 | this._timerEngineWebSocketUpdate(timerId, { 66 | timerId, 67 | ...update 68 | }) 69 | }, 70 | (update: MessageUpdate) => { 71 | this._timerEngineMessageUpdate(timerId, { 72 | ...update, 73 | timerId, 74 | }) 75 | }, 76 | async (audioFilePath) => { 77 | await this._playSound(timerId, audioFilePath) 78 | } 79 | ) 80 | 81 | let windows: WindowsKV = {} 82 | Object.keys(settings.windows).forEach(windowId => { 83 | const windowSettings = settings.windows[windowId] 84 | windows[windowId] = this._createWindow(timerId, settings.name, windowId, windowSettings) 85 | }) 86 | 87 | let ndiServers: NDIManagersKV = {} 88 | Object.keys(settings.windows).forEach(windowId => { 89 | const server = new NDIManager(`Countdown ${settings.name}-${windowId}`) 90 | server.start() 91 | ndiServers[windowId] = server 92 | }) 93 | 94 | this.timers[timerId] = { 95 | settings, 96 | engine: timerEngine, 97 | windows, 98 | ndiServers, 99 | } 100 | } 101 | 102 | private _createWindow(timerId: string, timerName: string, windowId: string, windowSettings: WindowSettings) { 103 | const countdownWindowHandler = createCountdownWindow(timerId, timerName, windowId, { 104 | x: windowSettings.bounds.x, 105 | y: windowSettings.bounds.y, 106 | height: windowSettings.bounds.height, 107 | width: windowSettings.bounds.width, 108 | // fullscreen: true 109 | frame: false, 110 | enableLargerThanScreen: true, 111 | transparent: true, 112 | alwaysOnTop: windowSettings.bounds.alwaysOnTop, 113 | }); 114 | 115 | countdownWindowHandler.onCreated(async function (browserWindow: BrowserWindow) { 116 | await this._setCountdownWindowPosition(countdownWindowHandler, windowSettings); 117 | }.bind(this)) 118 | 119 | return countdownWindowHandler 120 | } 121 | 122 | async _playSound(timerId: string, audioFilePath: string) { 123 | const mainBrowserWindow = this.app.mainWindowHandler.browserWindow; 124 | let audioFile; 125 | try { 126 | audioFile = await fs.readFile(audioFilePath, {encoding: 'base64'}) 127 | } catch { 128 | return 129 | } 130 | const mimeType = mime.getType(audioFilePath) 131 | 132 | mainBrowserWindow.webContents.send('audio:play', audioFile, mimeType) 133 | } 134 | 135 | _timerEngineUpdate(timerId: string, update: TimerEngineUpdate) { 136 | const mainBrowserWindow = this.app.mainWindowHandler.browserWindow; 137 | mainBrowserWindow.webContents.send('update', timerId, update); 138 | Object.keys(this.timers[timerId].windows).forEach(windowId => { 139 | const browserWinHandler = this.timers[timerId].windows[windowId]; 140 | browserWinHandler.browserWindow.webContents.send('update', update); 141 | }) 142 | } 143 | 144 | _timerEngineWebSocketUpdate(timerId: string, update: TimerEngineWebSocketUpdate) { 145 | this.app.webServer.sendToWebSocket({ 146 | type: 'timerEngine', 147 | update 148 | }) 149 | } 150 | 151 | _timerEngineMessageUpdate(timerId: string, update: MessageUpdate) { 152 | Object.keys(this.timers[timerId].windows).forEach(windowId => { 153 | const browserWinHandler = this.timers[timerId].windows[windowId]; 154 | browserWinHandler.browserWindow.webContents.send('message', update); 155 | }) 156 | } 157 | 158 | async _setCountdownWindowPosition(countdownWindowHandler: BrowserWinHandler, windowSettings: WindowSettings) { 159 | const browserWindow = countdownWindowHandler.browserWindow 160 | const fullscreenOn = windowSettings.bounds.fullscreenOn 161 | const selectedScreen = screen.getAllDisplays().find((display) => display.id === fullscreenOn) 162 | 163 | if (browserWindow.fullScreen && fullscreenOn === null) { 164 | browserWindow.setFullScreen(false) 165 | await new Promise(r => setTimeout(r, 1000)); 166 | } 167 | if (fullscreenOn !== null) { 168 | await sleep(1000) 169 | browserWindow.setPosition(selectedScreen.bounds.x + 100, selectedScreen.bounds.y + 100) 170 | browserWindow.setFullScreen(true) 171 | return; 172 | } 173 | 174 | browserWindow.setBounds({ 175 | x: windowSettings.bounds.x, 176 | y: windowSettings.bounds.y, 177 | height: windowSettings.bounds.height, 178 | width: windowSettings.bounds.width 179 | }) 180 | 181 | browserWindow.setAlwaysOnTop(windowSettings.bounds.alwaysOnTop) 182 | } 183 | 184 | destroyWindow(timerId: string, windowId: string) { 185 | this.timers[timerId].windows[windowId].browserWindow.destroy() 186 | delete this.timers[timerId].windows[windowId] 187 | } 188 | 189 | destroyTimer(timerId: string) { 190 | Object.keys(this.timers[timerId].windows).forEach(windowId => { 191 | this.destroyWindow(timerId, windowId) 192 | }) 193 | delete this.timers[timerId] 194 | } 195 | 196 | configUpdated() { 197 | Object.keys(this.timers).forEach(timerId => { 198 | const timersInSettings = this.app.config.settings.timers 199 | if (Object.keys(timersInSettings).includes(timerId)) { 200 | Object.keys(this.timers[timerId].windows).forEach(windowId => { 201 | if (!Object.keys(timersInSettings[timerId].windows).includes(windowId)) { 202 | this.destroyWindow(timerId, windowId) 203 | } 204 | }) 205 | } else { 206 | this.destroyTimer(timerId) 207 | } 208 | }) 209 | 210 | Object.keys(this.app.config.settings.timers).forEach(timerId => { 211 | const timer = this.app.config.settings.timers[timerId]; 212 | 213 | if (Object.keys(this.timers).includes(timerId)) { 214 | const options: TimerEngineOptions = { 215 | yellowAtOption: timer.yellowAtOption, 216 | yellowAt: timer.yellowAtOption === 'minutes' ? timer.yellowAtMinutes : timer.yellowAtPercent, 217 | stopTimerAtZero: timer.stopTimerAtZero, 218 | setTimeLive: timer.setTimeLive, 219 | audioFile: timer.audioFile, 220 | } 221 | 222 | this.timers[timerId].engine.options = options 223 | this.timers[timerId].engine.setTimerInterval(timer.timerDuration) 224 | this.timers[timerId].settings = timer 225 | 226 | Object.keys(timer.windows).forEach(windowId => { 227 | if (!Object.keys(this.timers[timerId].windows).includes(windowId)) { 228 | this.timers[timerId].windows[windowId] = this._createWindow(timerId, timer.name, windowId, timer.windows[windowId]) 229 | } 230 | }) 231 | 232 | Object.keys(this.timers[timerId].windows).forEach(windowId => { 233 | const windowHandler = this.timers[timerId].windows[windowId] 234 | windowHandler.browserWindow.webContents.send('settings:updated', timer.windows[windowId]) 235 | }) 236 | } else { 237 | this.createTimer(timerId, timer) 238 | } 239 | }) 240 | } 241 | 242 | windowUpdated(timerId: string, windowId: number): void { 243 | const windowHandler = this.timers[timerId].windows[windowId] 244 | const windowSettings = this.timers[timerId].settings.windows[windowId]; 245 | 246 | this._setCountdownWindowPosition(windowHandler, windowSettings) 247 | } 248 | 249 | getWindowBounds(timerId: string, windowId: string): WindowBounds { 250 | const windowHandler = this.timers[timerId].windows[windowId] 251 | const windowBounds = windowHandler.browserWindow.getBounds() 252 | const windowSettings = this.timers[timerId].settings.windows[windowId] 253 | 254 | return { 255 | alwaysOnTop: windowSettings.bounds.alwaysOnTop, 256 | width: windowBounds.width, 257 | height: windowBounds.height, 258 | x: windowBounds.x, 259 | y: windowBounds.y, 260 | fullscreenOn: windowSettings.bounds.fullscreenOn 261 | } 262 | } 263 | 264 | sendNDIFrames() { 265 | try { 266 | Object.keys(this.timers).forEach(timerId => { 267 | const timer = this.timers[timerId]; 268 | 269 | Object.keys(timer.windows).forEach(async windowId => { 270 | const windowHandler = this.timers[timerId].windows[windowId] 271 | const ndiServer = timer.ndiServers[windowId] 272 | if (!ndiServer.hasConnections()) return 273 | const image = await windowHandler.browserWindow.webContents.capturePage() 274 | await timer.ndiServers[windowId].sendFrame(image) 275 | }) 276 | }) 277 | } catch (e) { 278 | console.log(e); 279 | return; 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/main/Utilities/addDefaultEvents.ts: -------------------------------------------------------------------------------- 1 | import {app} from "electron"; 2 | import * as process from "process"; 3 | 4 | export default function addDefaultEvents() { 5 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 6 | if (require('electron-squirrel-startup')) { 7 | app.quit(); 8 | } 9 | 10 | app.on('window-all-closed', () => { 11 | if (process.platform !== 'darwin') { 12 | app.quit(); 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/Utilities/addIpcHandles.ts: -------------------------------------------------------------------------------- 1 | import {dialog, ipcMain, screen} from "electron"; 2 | import {CountdownApp} from "../App.ts"; 3 | import {IpcGetWindowSettingsArgs} from "../../common/IpcInterfaces.ts"; 4 | import {promises as fs} from "node:fs"; 5 | 6 | export default function addIpcHandles(app: CountdownApp) 7 | { 8 | ipcMain.handle('screens:get', () => { 9 | return screen.getAllDisplays() 10 | }) 11 | 12 | ipcMain.on('window-updated', async (event, timerId, windowId) => { 13 | app.timersOrchestrator.windowUpdated(timerId, windowId) 14 | }) 15 | 16 | ipcMain.on('temporary-settings-updated', (event, arg) => { 17 | const browserWindow = app.countdownWindowHandler.browserWindow 18 | browserWindow.webContents.send('temporary-settings-updated', arg) 19 | }) 20 | 21 | ipcMain.on('send-to-countdown-window', (event, arg) => { 22 | const browserWindow = app.countdownWindowHandler.browserWindow 23 | browserWindow.webContents.send('command', arg) 24 | }) 25 | 26 | ipcMain.handle('countdown-bounds', (event, timerId: string, windowId: string) => { 27 | return app.timersOrchestrator.getWindowBounds(timerId, windowId) 28 | }) 29 | 30 | ipcMain.on('current-timer:set', (event, timerId: string) => { 31 | app.timersOrchestrator.currentTimer = timerId; 32 | }) 33 | 34 | ipcMain.handle('settings:get', (event, key: string) => { 35 | return app.config.get(key) 36 | }) 37 | 38 | ipcMain.handle('settings:set', (event, key: string, value: string) => { 39 | const newSettings = app.config.set(key, JSON.parse(value)) 40 | 41 | app.timersOrchestrator.configUpdated() 42 | 43 | if (newSettings.remote.ndiEnabled) { 44 | app.ndiServer.start(); 45 | app.startNdiTimer(); 46 | } else { 47 | app.ndiServer.stop() 48 | app.stopNdiTimer(); 49 | } 50 | if (newSettings.remote.oscEnabled) { 51 | app.oscServer.port = newSettings.remote.oscPort; 52 | app.oscServer.start(); 53 | } else { 54 | app.oscServer.stop(); 55 | } 56 | app.ndiServer.alpha = newSettings.remote.ndiAlpha; 57 | 58 | return newSettings 59 | }) 60 | 61 | ipcMain.handle('settings:get-window', (event, args: IpcGetWindowSettingsArgs) => { 62 | return app.config.settings.timers[args.timerId].windows[args.windowId] 63 | }) 64 | 65 | ipcMain.handle('audio:select-file', async () => { 66 | const result = await dialog.showOpenDialog(null, { 67 | properties: ['openFile'], 68 | filters: [{ 69 | name: 'Audio file', 70 | extensions: ['wav', 'mp3', 'flac'], 71 | }] 72 | }) 73 | if (result.canceled) return null; 74 | return result.filePaths[0] 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /src/main/Utilities/dev.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import installExtension from 'electron-devtools-installer'; 3 | 4 | const VUEJS3_DEVTOOLS = { 5 | id: 'nhdogjmejiglipccpnnnanhbledajbpd', 6 | electron: '>=1.2.1' 7 | } 8 | 9 | function enableDevMode() { 10 | app.whenReady().then(() => { 11 | installExtension(VUEJS3_DEVTOOLS) 12 | .then((name) => console.log(`Added Extension: ${name}`)) 13 | .catch((err) => console.log('An error occurred: ', err)); 14 | }); 15 | } 16 | 17 | const isDev = process.env.NODE_ENV === 'development' 18 | 19 | export { 20 | enableDevMode, 21 | isDev, 22 | } 23 | -------------------------------------------------------------------------------- /src/main/Utilities/setMenu.js: -------------------------------------------------------------------------------- 1 | import {app, Menu, shell} from "electron"; 2 | 3 | const isMac = process.platform === 'darwin' 4 | 5 | export default (mainWindowHandler, timersOrchestrator) => { 6 | const template = [ 7 | // { role: 'appMenu' } 8 | ...(isMac ? [{ 9 | label: app.name, 10 | submenu: [ 11 | { role: 'about' }, 12 | { type: 'separator' }, 13 | { role: 'services' }, 14 | { type: 'separator' }, 15 | { role: 'hide' }, 16 | { role: 'hideOthers' }, 17 | { role: 'unhide' }, 18 | { type: 'separator' }, 19 | { role: 'quit' } 20 | ] 21 | }] : []), 22 | // { role: 'fileMenu' } 23 | { 24 | label: 'Control', 25 | submenu: [ 26 | { 27 | label: 'Start/Pause', 28 | accelerator: 'CommandOrControl+Enter', 29 | click: async () => { 30 | if (timersOrchestrator.currentTimer) { 31 | timersOrchestrator.timers[timersOrchestrator.currentTimer].engine.startResumePause(); 32 | } 33 | }, 34 | }, 35 | { 36 | label: 'Reset', 37 | accelerator: 'CommandOrControl+R', 38 | click: async () => { 39 | if (timersOrchestrator.currentTimer) { 40 | timersOrchestrator.timers[timersOrchestrator.currentTimer].engine.reset(); 41 | } 42 | } 43 | } 44 | ] 45 | }, 46 | { 47 | label: 'File', 48 | submenu: [ 49 | isMac ? { role: 'close' } : { role: 'quit' } 50 | ] 51 | }, 52 | // { role: 'editMenu' } 53 | { 54 | label: 'Edit', 55 | submenu: [ 56 | { role: 'undo' }, 57 | { role: 'redo' }, 58 | { type: 'separator' }, 59 | { role: 'cut' }, 60 | { role: 'copy' }, 61 | { role: 'paste' }, 62 | ...(isMac ? [ 63 | { role: 'pasteAndMatchStyle' }, 64 | { role: 'delete' }, 65 | { role: 'selectAll' }, 66 | { type: 'separator' }, 67 | { 68 | label: 'Speech', 69 | submenu: [ 70 | { role: 'startSpeaking' }, 71 | { role: 'stopSpeaking' } 72 | ] 73 | } 74 | ] : [ 75 | { role: 'delete' }, 76 | { type: 'separator' }, 77 | { role: 'selectAll' } 78 | ]) 79 | ] 80 | }, 81 | // { role: 'viewMenu' } 82 | { 83 | label: 'View', 84 | submenu: [ 85 | //{ role: 'reload' }, 86 | { role: 'forceReload' }, 87 | { role: 'toggleDevTools' }, 88 | { type: 'separator' }, 89 | { role: 'resetZoom' }, 90 | { role: 'zoomIn' }, 91 | { role: 'zoomOut' }, 92 | { type: 'separator' }, 93 | { role: 'togglefullscreen' } 94 | ] 95 | }, 96 | // { role: 'windowMenu' } 97 | { 98 | label: 'Window', 99 | submenu: [ 100 | { role: 'minimize' }, 101 | { role: 'zoom' }, 102 | ...(isMac ? [ 103 | { type: 'separator' }, 104 | { role: 'front' }, 105 | { type: 'separator' }, 106 | { role: 'window' } 107 | ] : [ 108 | { role: 'close' } 109 | ]) 110 | ] 111 | }, 112 | { 113 | role: 'help', 114 | submenu: [ 115 | { 116 | label: 'By CVM Eventi', 117 | click: async () => { 118 | await shell.openExternal('https://cvm.it') 119 | } 120 | } 121 | ] 122 | } 123 | ] 124 | 125 | const menu = Menu.buildFromTemplate(template) 126 | Menu.setApplicationMenu(menu) 127 | } 128 | -------------------------------------------------------------------------------- /src/main/Utilities/utilities.ts: -------------------------------------------------------------------------------- 1 | export function sleep (ms: number) { 2 | return new Promise(resolve => { 3 | setTimeout(resolve, ms) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/countdownWindow.ts: -------------------------------------------------------------------------------- 1 | import BrowserWinHandler from './Utilities/BrowserWinHandler.ts' 2 | import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions; 3 | 4 | export default function createCountdownWindow (timerId: string, timerName: string, windowId: string, options: BrowserWindowConstructorOptions) { 5 | const winHandler = new BrowserWinHandler({...options, title: `Countdown ${timerName} - ${windowId}`}); 6 | 7 | winHandler.onCreated(() => { 8 | winHandler.loadPage(`/countdown`, { 9 | "timer": timerId, 10 | "window": windowId 11 | }); 12 | // _browserWindow.toggleDevTools() 13 | }) 14 | 15 | return winHandler 16 | } 17 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import "v8-compile-cache"; 2 | import { CountdownApp } from './App.ts'; 3 | import { updateElectronApp } from "update-electron-app"; 4 | updateElectronApp({ 5 | updateInterval: '8 hours' 6 | }) 7 | 8 | const countdownApp = new CountdownApp(); 9 | countdownApp.run() 10 | -------------------------------------------------------------------------------- /src/main/mainWindow.ts: -------------------------------------------------------------------------------- 1 | import BrowserWinHandler from './Utilities/BrowserWinHandler.ts' 2 | import { isDev } from './Utilities/dev.ts' 3 | import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions; 4 | import {APP_VERSION} from "../version.ts"; 5 | 6 | export default function createMainWindow (options: BrowserWindowConstructorOptions = {}) { 7 | options = { 8 | ...{ 9 | height: 590, 10 | width: 920, 11 | minWidth: 920, 12 | minHeight: 590, 13 | title: `Countdown v${APP_VERSION}` 14 | }, 15 | ...options, 16 | } 17 | const winHandler = new BrowserWinHandler(options) 18 | 19 | winHandler.onCreated(async () => { 20 | await winHandler.loadPage('/control') 21 | // Or load custom url 22 | // _browserWindow.loadURL('https://google.com') 23 | if (isDev) { 24 | winHandler.browserWindow.webContents.openDevTools(); 25 | } 26 | }) 27 | 28 | return winHandler 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 28 | 29 | 32 | -------------------------------------------------------------------------------- /src/renderer/TimerControl.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer} from "electron"; 2 | import {TimerEngineUpdate} from "../common/TimerInterfaces.ts"; 3 | import { 4 | IpcJogCurrent, 5 | IpcJogSet, 6 | IpcMessage, 7 | IpcPause, 8 | IpcReset, 9 | IpcResume, 10 | IpcSetSeconds, 11 | IpcStart, 12 | IpcTimerCommandName, 13 | IpcTogglePause 14 | } from "../common/IpcInterfaces.ts"; 15 | 16 | export class TimerControl { 17 | 18 | async set(timerId: string, seconds: number) { 19 | const command: IpcSetSeconds = { 20 | name: IpcTimerCommandName.SetSeconds, 21 | timerId, 22 | seconds, 23 | }; 24 | await ipcRenderer.invoke('command', command); 25 | } 26 | 27 | async start(timerId: string) { 28 | const command: IpcStart = { 29 | name: IpcTimerCommandName.Start, 30 | timerId, 31 | }; 32 | await ipcRenderer.invoke('command', command); 33 | } 34 | 35 | async reset(timerId: string) { 36 | const command: IpcReset = { 37 | name: IpcTimerCommandName.Reset, 38 | timerId, 39 | }; 40 | await ipcRenderer.invoke('command', command); 41 | } 42 | 43 | async toggle(timerId: string) { 44 | const command: IpcTogglePause = { 45 | name: IpcTimerCommandName.TogglePause, 46 | timerId, 47 | }; 48 | await ipcRenderer.invoke('command', command); 49 | } 50 | 51 | async pause(timerId: string) { 52 | const command: IpcPause = { 53 | name: IpcTimerCommandName.Pause, 54 | timerId, 55 | }; 56 | await ipcRenderer.invoke('command', command); 57 | } 58 | 59 | async resume(timerId: string) { 60 | const command: IpcResume = { 61 | name: IpcTimerCommandName.Resume, 62 | timerId, 63 | }; 64 | await ipcRenderer.invoke('command', command); 65 | } 66 | 67 | async jogSet(timerId: string, seconds: number) { 68 | const command: IpcJogSet = { 69 | name: IpcTimerCommandName.JogSet, 70 | timerId, 71 | seconds, 72 | } 73 | await ipcRenderer.invoke('command', command); 74 | } 75 | 76 | async jogCurrent(timerId: string, seconds: number) { 77 | const command: IpcJogCurrent = { 78 | name: IpcTimerCommandName.JogCurrent, 79 | timerId, 80 | seconds, 81 | }; 82 | await ipcRenderer.invoke('command', command); 83 | } 84 | 85 | async sendMessage(timerId: string, message: string) { 86 | const command: IpcMessage = { 87 | name: IpcTimerCommandName.Message, 88 | timerId, 89 | message, 90 | }; 91 | await ipcRenderer.invoke('command', command); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/renderer/assets/fonts/B612Mono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/renderer/assets/fonts/B612Mono-Regular.ttf -------------------------------------------------------------------------------- /src/renderer/assets/fonts/XanhMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/renderer/assets/fonts/XanhMono-Regular.ttf -------------------------------------------------------------------------------- /src/renderer/assets/fonts/digital-7.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/renderer/assets/fonts/digital-7.woff2 -------------------------------------------------------------------------------- /src/renderer/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/renderer/assets/images/logo.png -------------------------------------------------------------------------------- /src/renderer/assets/sounds/gong.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CVMEventi/Countdown/73ab3d24be8720d3143669d7e915df1ccb13fff5/src/renderer/assets/sounds/gong.mp3 -------------------------------------------------------------------------------- /src/renderer/components/BaseContainer.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /src/renderer/components/Card.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /src/renderer/components/CheckBox.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /src/renderer/components/Clock.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 55 | 56 | 75 | -------------------------------------------------------------------------------- /src/renderer/components/ColorInput.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 91 | 92 | 97 | -------------------------------------------------------------------------------- /src/renderer/components/CreateTimerModal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | -------------------------------------------------------------------------------- /src/renderer/components/DeleteTimerModal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 46 | -------------------------------------------------------------------------------- /src/renderer/components/Drawer.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 40 | -------------------------------------------------------------------------------- /src/renderer/components/EditPreset.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 53 | 54 | 62 | -------------------------------------------------------------------------------- /src/renderer/components/EditTimerModal.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 88 | -------------------------------------------------------------------------------- /src/renderer/components/InputWithButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | 38 | -------------------------------------------------------------------------------- /src/renderer/components/Jog.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /src/renderer/components/Navigation.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 51 | 52 | 55 | -------------------------------------------------------------------------------- /src/renderer/components/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 35 | 36 | 41 | -------------------------------------------------------------------------------- /src/renderer/components/RemoteTimer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 72 | 73 | 76 | -------------------------------------------------------------------------------- /src/renderer/components/SButton.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 61 | 62 | 65 | -------------------------------------------------------------------------------- /src/renderer/components/ScreensDrag.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 160 | 161 | 171 | -------------------------------------------------------------------------------- /src/renderer/components/SidebarMenu.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 60 | 61 | 64 | -------------------------------------------------------------------------------- /src/renderer/components/StoresUpdater.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 81 | 82 | 85 | -------------------------------------------------------------------------------- /src/renderer/components/TabButton.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 41 | 42 | 48 | -------------------------------------------------------------------------------- /src/renderer/components/TimeInput.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 139 | 140 | 143 | -------------------------------------------------------------------------------- /src/renderer/components/TimerTabButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /src/renderer/components/TimersNavigation.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /src/renderer/components/TopBar.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | 30 | 33 | -------------------------------------------------------------------------------- /src/renderer/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | .input { 7 | @apply border-0 rounded-lg px-3 py-1 bg-zinc-700 text-white; 8 | } 9 | } 10 | 11 | 12 | .input-big { 13 | @apply text-5xl 14 | } 15 | 16 | .text-clock, .text-time { 17 | line-height: 1; 18 | } 19 | 20 | .disabled { 21 | @apply text-gray-700 22 | } 23 | 24 | .input-number-fixed { 25 | @apply w-24 text-center border-0 font-mono 26 | } 27 | 28 | .input::-webkit-outer-spin-button, 29 | .input::-webkit-inner-spin-button { 30 | -webkit-appearance: none; 31 | margin: 0; 32 | } 33 | 34 | @font-face { 35 | font-family: digital-7; 36 | src: url("./assets/fonts/digital-7.woff2"); 37 | } 38 | 39 | @font-face { 40 | font-family: B612; 41 | src: url("./assets/fonts/B612Mono-Regular.ttf"); 42 | } 43 | 44 | @font-face { 45 | font-family: Xanh; 46 | src: url("./assets/fonts/XanhMono-Regular.ttf"); 47 | } 48 | 49 | html, body, #app, #app > div { 50 | height: 100%; 51 | width: 100%; 52 | } 53 | 54 | #app { 55 | @apply overflow-x-hidden; 56 | } 57 | 58 | input[type="color"] { 59 | -webkit-appearance: none; 60 | @apply border border-gray-300 rounded; 61 | } 62 | 63 | input[type="color"]::-webkit-color-swatch-wrapper { 64 | padding: 0; 65 | } 66 | input[type="color"]::-webkit-color-swatch { 67 | border: none; 68 | } 69 | 70 | @import url('vue-accessible-color-picker/styles'); 71 | 72 | .vacp-color-picker { 73 | @apply text-black; 74 | } 75 | 76 | .vacp-color-input { 77 | @apply border border-gray-300 rounded; 78 | } 79 | 80 | .vacp-color-space { 81 | @apply border border-gray-300; 82 | } 83 | 84 | select.input { 85 | @apply py-0.5 text-sm rounded-lg; 86 | } 87 | 88 | input[type="text"], input[type="number"] { 89 | @apply py-0.5; 90 | } 91 | 92 | ::-webkit-scrollbar { 93 | width: 20px; 94 | } 95 | ::-webkit-scrollbar-corner { 96 | background: rgba(0,0,0,0); 97 | } 98 | ::-webkit-scrollbar-thumb { 99 | cursor: pointer; 100 | background-color: #ccc; 101 | border-radius: 10px; 102 | border: 4px solid rgba(0,0,0,0); 103 | background-clip: content-box; 104 | min-width: 32px; 105 | min-height: 32px; 106 | } 107 | ::-webkit-scrollbar-track { 108 | background-color: rgba(0,0,0,0); 109 | } 110 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import "v8-compile-cache"; 2 | import './index.css'; 3 | import { createApp } from 'vue'; 4 | import Control from './pages/Control.vue'; 5 | import Countdown from './pages/Countdown.vue'; 6 | import App from './App.vue'; 7 | import { createRouter, createWebHashHistory } from 'vue-router'; 8 | import {createPinia} from "pinia"; 9 | import TimersSettings from './pages/TimersSettings.vue' 10 | import RemoteSettings from './pages/RemoteSettings.vue' 11 | import GeneralSettings from './pages/GeneralSettings.vue' 12 | import {useSettingsStore} from './stores/settings.ts' 13 | import {useGlobalStore} from './stores/global.ts' 14 | import {useTimersStore} from './stores/timers.ts' 15 | import {usePiniaWebpackHotHMR} from './piniaHmr.ts' 16 | 17 | if (import.meta.webpackHot) { 18 | usePiniaWebpackHotHMR(import.meta.webpackHot, [useSettingsStore, useGlobalStore, useTimersStore]); 19 | } 20 | 21 | const routes = [ 22 | { path: '/countdown', component: Countdown }, 23 | { path: '/control', component: Control }, 24 | { path: '/settings/timers', component: TimersSettings }, 25 | { path: '/settings/remote', component: RemoteSettings }, 26 | { path: '/settings/general', component: GeneralSettings }, 27 | ] 28 | 29 | const router = createRouter({ 30 | history: createWebHashHistory(), 31 | routes, 32 | }) 33 | 34 | const pinia = createPinia() 35 | const app = createApp(App) 36 | app.use(pinia) 37 | app.use(router) 38 | app.mount('#app') 39 | -------------------------------------------------------------------------------- /src/renderer/pages/Control.vue: -------------------------------------------------------------------------------- 1 | 99 | 100 | 203 | 204 | 220 | -------------------------------------------------------------------------------- /src/renderer/pages/Countdown.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 159 | 160 | 188 | -------------------------------------------------------------------------------- /src/renderer/pages/GeneralSettings.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 61 | 62 | 67 | -------------------------------------------------------------------------------- /src/renderer/pages/RemoteSettings.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 159 | 160 | 163 | -------------------------------------------------------------------------------- /src/renderer/pages/TimersSettings.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 211 | 212 | 215 | -------------------------------------------------------------------------------- /src/renderer/piniaHmr.ts: -------------------------------------------------------------------------------- 1 | import { getActivePinia } from 'pinia'; 2 | 3 | export function usePiniaWebpackHotHMR(hot, store) 4 | { 5 | hot.accept(); 6 | const stores = Array.isArray(store) ? store : [store]; 7 | 8 | stores.forEach((storeInstance) => 9 | { 10 | hot.dispose((data) => 11 | { 12 | (data).initialUseStoreId = storeInstance.$id; 13 | }); 14 | const piniaAccept = acceptWebpackHMRUpdate(storeInstance.$id, hot); 15 | piniaAccept(storeInstance); 16 | }); 17 | } 18 | 19 | /** 20 | * Checks if a function is a `StoreDefinition`. 21 | * 22 | * @param fn - object to test 23 | * @returns true if `fn` is a StoreDefinition 24 | */ 25 | export const isUseStore = (fn) => typeof fn === 'function' && typeof fn.$id === 'string'; 26 | 27 | export function acceptWebpackHMRUpdate(initialUseStoreId, hot) 28 | { 29 | // strip as much as possible from iife.prod 30 | if (process.env.NODE_ENV === 'production') 31 | { 32 | return () => 33 | {}; 34 | } 35 | return (newModule) => 36 | { 37 | const pinia = hot.data?.pinia || getActivePinia(); 38 | if (!pinia) 39 | { 40 | // this store is still not used 41 | return; 42 | } 43 | 44 | // preserve the pinia instance across loads 45 | // hot.data.pinia = pinia // FIXME: this doesn't work as data does not let us set functions. 46 | 47 | if (isUseStore(newModule) && pinia._s.has(newModule.$id)) 48 | { 49 | console.log(`[HMR-Pinia] Accepting update for "${newModule.$id}"`); 50 | const id = newModule.$id; 51 | 52 | if (id !== initialUseStoreId) 53 | { 54 | console.warn(`The ID of the store changed from "${initialUseStoreId}" to "${id}". Reloading.`); 55 | // return import.meta.hot.invalidate() 56 | return hot.invalidate(); // eslint-disable-line consistent-return 57 | } 58 | 59 | const existingStore = pinia._s.get(id); 60 | if (!existingStore) 61 | { 62 | console.log(`[Pinia]: skipping HMR of "${id}" because store doesn't exist yet`); 63 | return; 64 | } 65 | newModule(pinia, existingStore); 66 | } 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/stores/global.ts: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia' 2 | import {ref} from 'vue' 3 | 4 | export const useGlobalStore = defineStore('global', () => { 5 | const sidebarOpen = ref(false) 6 | const currentTimer = ref(null) 7 | 8 | return { sidebarOpen, currentTimer } 9 | }) 10 | -------------------------------------------------------------------------------- /src/renderer/stores/settings.ts: -------------------------------------------------------------------------------- 1 | 2 | import {defineStore} from 'pinia'; 3 | import {ref} from 'vue' 4 | import { 5 | CountdownSettings, 6 | DEFAULT_CLOSE_ACTION, 7 | DEFAULT_REMOTE_SETTINGS, 8 | DEFAULT_START_HIDDEN 9 | } from '../../common/config.ts' 10 | 11 | 12 | export const useSettingsStore = defineStore('settings', () => { 13 | let settings = ref({ 14 | presets: [], 15 | remote: DEFAULT_REMOTE_SETTINGS, 16 | setWindowAlwaysOnTop: false, 17 | closeAction: DEFAULT_CLOSE_ACTION, 18 | startHidden: DEFAULT_START_HIDDEN, 19 | timers: {}, 20 | }); 21 | 22 | return { settings }; 23 | }); 24 | -------------------------------------------------------------------------------- /src/renderer/stores/timers.ts: -------------------------------------------------------------------------------- 1 | import {defineStore} from "pinia"; 2 | import {ref} from "vue"; 3 | import {TimerEngineUpdates} from "../../common/TimerInterfaces.ts"; 4 | 5 | export const useTimersStore = defineStore('timers', () => { 6 | const updates = ref({}) 7 | 8 | return {updates} 9 | }) 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'jit', 3 | content: [ 4 | './src/renderer/**/*.{vue,js,ts,html}', 5 | ], 6 | theme: { 7 | extend: { 8 | fontSize: { 9 | '10xl': '10rem', 10 | }, 11 | animation: { 12 | 'pulse-fast': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;', 13 | } 14 | } 15 | }, 16 | variants: { 17 | extend: {} 18 | }, 19 | plugins: [ 20 | require('@tailwindcss/forms'), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowImportingTsExtensions": true, 4 | "target": "ES6", 5 | "allowJs": true, 6 | "module": "node16", 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "sourceMap": true, 11 | "baseUrl": ".", 12 | "outDir": "dist", 13 | "moduleResolution": "node16", 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "paths": { 17 | "*": ["node_modules/*"] 18 | } 19 | }, 20 | "include": ["types/index.d.ts", "src/**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: string; 3 | export = value; 4 | } 5 | 6 | declare module '*.ico' { 7 | const value: string; 8 | export = value; 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, // <-- ** 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /webpack.main.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from 'webpack'; 2 | import { rules } from "./webpack.rules"; 3 | 4 | rules.push({ 5 | test: /\.(woff|woff2|eot|ttf|otf|png|jpeg|jpg|mp3|ico)$/i, 6 | type: 'asset/resource', 7 | }) 8 | 9 | export const mainConfig: Configuration = { 10 | /** 11 | * This is the main entry point for your application, it's the first file 12 | * that runs in the main process. 13 | */ 14 | entry: './src/main/index.ts', 15 | // Put your normal webpack config below here 16 | module: { 17 | rules: rules, 18 | }, 19 | externals: { 20 | grandiose: 'grandiose' 21 | }, 22 | resolve: { 23 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json'], 24 | }, 25 | output: { 26 | // [file] is the key thing here. [query] and [fragment] are optional 27 | assetModuleFilename: '[file][query][fragment]', 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /webpack.plugins.ts: -------------------------------------------------------------------------------- 1 | import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; 2 | 3 | module.exports = [ 4 | new ForkTsCheckerWebpackPlugin() 5 | ]; 6 | -------------------------------------------------------------------------------- /webpack.renderer.config.ts: -------------------------------------------------------------------------------- 1 | import { rules } from './webpack.rules'; 2 | import {VueLoaderPlugin} from "vue-loader"; 3 | import MiniCssExtractPlugin from "mini-css-extract-plugin"; 4 | import CssMinimizerPlugin from "css-minimizer-webpack-plugin"; 5 | const isProd = process.env.NODE_ENV === 'production'; 6 | import {Configuration, DefinePlugin} from 'webpack'; 7 | import path from "path"; 8 | 9 | rules.push({ 10 | test: /\.css$/i, 11 | use: [ 12 | isProd ? MiniCssExtractPlugin.loader : 'style-loader', 13 | "css-loader", 14 | 'postcss-loader', 15 | ], 16 | }); 17 | 18 | rules.push({ 19 | test: /\.vue$/, 20 | loader: 'vue-loader' 21 | }) 22 | 23 | rules.push({ 24 | test: /\.(woff|woff2|eot|ttf|otf|png|jpeg|jpg|mp3)$/i, 25 | type: 'asset/resource', 26 | }) 27 | 28 | export const rendererConfig: Configuration = { 29 | target: 'electron-renderer', 30 | // Put your normal webpack config below here 31 | module: { 32 | rules, 33 | }, 34 | optimization: { 35 | minimizer: [ 36 | new CssMinimizerPlugin(), 37 | ], 38 | }, 39 | plugins: [ 40 | // make sure to include the plugin! 41 | new VueLoaderPlugin(), 42 | new MiniCssExtractPlugin(), 43 | new DefinePlugin({ 44 | __VUE_OPTIONS_API__: 'true', 45 | __VUE_PROD_DEVTOOLS__: 'false', 46 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' 47 | }) 48 | ], 49 | resolve: { 50 | alias: { 51 | vue: 'vue/dist/vue.esm-bundler.js', 52 | '@': path.resolve(__dirname, 'src/renderer'), 53 | }, 54 | extensions: ['.js', '.ts', '.vue', '.css'], 55 | }, 56 | }; 57 | 58 | -------------------------------------------------------------------------------- /webpack.rules.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleOptions } from 'webpack'; 2 | 3 | export const rules: Required['rules'] = [ 4 | // Add support for native node modules 5 | { 6 | // We're specifying native_modules in the test because the asset relocator loader generates a 7 | // "fake" .node file which is really a cjs file. 8 | test: /native_modules[/\\].+\.node$/, 9 | use: 'node-loader', 10 | }, 11 | { 12 | test: /[/\\]node_modules[/\\].+\.(m?js|node)$/, 13 | parser: { amd: false }, 14 | use: { 15 | loader: '@vercel/webpack-asset-relocator-loader', 16 | options: { 17 | outputAssetBase: 'native_modules', 18 | }, 19 | }, 20 | }, 21 | { 22 | test: /\.tsx?$/, 23 | exclude: /(node_modules|\.webpack)/, 24 | use: { 25 | loader: 'ts-loader', 26 | options: { 27 | transpileOnly: true, 28 | }, 29 | }, 30 | }, 31 | ]; 32 | --------------------------------------------------------------------------------