diff --git a/.gitignore b/.gitignore index 386035b5..98d1f194 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ lib-cov coverage *.lcov +.vs # nyc test coverage .nyc_output diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b6b9a64..ad09500d 100755 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,6 +25,12 @@ "presentation": { "hidden": true } + }, + { + "type": "lldb", + "request": "attach", + "name": "Debug Electron Renderer", + "pid": "${command:pickProcess}" } ], "compounds": [ diff --git a/.vscode/settings.json b/.vscode/settings.json index 4c05394e..adcec785 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,8 @@ }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.associations": { + "xstring": "cpp" } } diff --git a/backend/extern/libuiohook b/backend/extern/libuiohook new file mode 160000 index 00000000..d8c3694e --- /dev/null +++ b/backend/extern/libuiohook @@ -0,0 +1 @@ +Subproject commit d8c3694ec01d322fcdf8181a39bddcf2cd347c0f diff --git a/backend/vcpkg b/backend/vcpkg index 57637915..e60236ee 160000 --- a/backend/vcpkg +++ b/backend/vcpkg @@ -1 +1 @@ -Subproject commit 576379156e82da642f8d1834220876759f13534d +Subproject commit e60236ee051183f1122066bee8c54a0b47c43a60 diff --git a/dev-app-update.yml b/dev-app-update.yml new file mode 100644 index 00000000..5e1e7a31 --- /dev/null +++ b/dev-app-update.yml @@ -0,0 +1,3 @@ +provider: generic +url: https://example.com/auto-updates +updaterCacheDirName: trackaudio-updater diff --git a/electron-builder-config.js b/electron-builder-config.js index 1e41af09..bf65fe91 100644 --- a/electron-builder-config.js +++ b/electron-builder-config.js @@ -4,6 +4,11 @@ module.exports = { directories: { buildResources: 'build' }, + publish: { + provider: 's3', + bucket: 'trackaudio', + region: 'eu-west-2' + }, files: [ '!**/.vscode/*', '!src/*', diff --git a/package-lock.json b/package-lock.json index d9750b1f..7d7446d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,11 @@ "bootstrap-typeahead": "^2.3.2", "clsx": "^2.1.1", "electron-store": "^8.2.0", + "electron-updater": "^6.3.9", "react": "^18.3.1", "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", + "react-responsive": "^10.0.0", "scss": "^0.2.4", "trackaudio-afv": "file:backend/trackaudio-afv-1.0.0.tgz", "use-debounce": "^10.0.1", @@ -35,7 +37,7 @@ "@types/react": "^18.3.3", "@vitejs/plugin-react": "^4.0.0", "electron": "^32.0.2", - "electron-builder": "24.13.3", + "electron-builder": "^24.13.3", "electron-vite": "^2.0.0", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.2", @@ -2634,8 +2636,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", @@ -3613,6 +3614,11 @@ "node": ">= 8" } }, + "node_modules/css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4191,6 +4197,65 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.9.tgz", + "integrity": "sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==", + "dependencies": { + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "^7.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-vite": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-2.0.0.tgz", @@ -5529,6 +5594,11 @@ "node": ">= 6" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -6168,7 +6238,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6265,8 +6334,7 @@ "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==" }, "node_modules/lazystream": { "version": "1.0.1", @@ -6362,6 +6430,11 @@ "dev": true, "peer": true }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==" + }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -6369,6 +6442,11 @@ "dev": true, "peer": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -6444,6 +6522,14 @@ "node": ">=10" } }, + "node_modules/matchmediaquery": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz", + "integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==", + "dependencies": { + "css-mediaquery": "^0.1.2" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7277,6 +7363,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-responsive": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.0.tgz", + "integrity": "sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg==", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.4.2", + "prop-types": "^15.6.1", + "shallow-equal": "^3.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/read-config-file": { "version": "6.3.2", "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", @@ -7639,8 +7742,7 @@ "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" }, "node_modules/scheduler": { "version": "0.23.2", @@ -7663,10 +7765,9 @@ } }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "license": "ISC", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -7729,6 +7830,11 @@ "node": ">= 0.4" } }, + "node_modules/shallow-equal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz", + "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8153,6 +8259,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -8196,8 +8307,7 @@ "node_modules/trackaudio-afv": { "version": "1.0.0", "resolved": "file:backend/trackaudio-afv-1.0.0.tgz", - "integrity": "sha512-oK6MjM//VaGwM58DCQJl4hf4B2rA3TSM7uzVz/s3ta0tS4r52scjZohZIM7H8k9tGrZDOdoFsVGAAE8jvVapaA==", - "license": "GPL-3.0-only", + "integrity": "sha512-nImUxtANHb8n2YvRHtSja+GPUeAXO+MivFKvjQIzF1r5IkNN2P0lnwFT39xb0WLRa+bWhiGW1ORBP4ubX8npzA==", "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^1.1.0" diff --git a/package.json b/package.json index cd2c462c..fe30fa22 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "build:win": "npm run build && electron-builder --win -c electron-builder-config.js", "build:mac": "npm run build && electron-builder --mac -c electron-builder-config.js", "build:linux": "npm run build && electron-builder --linux -c electron-builder-config.js", + "build:publish": "npm run build && electron-builder --win -c electron-builder-config.js --publish=always", "build:backend": "cd backend && npm install && npm run build && cd .. && npm install ./backend/trackaudio-afv-1.0.0.tgz", "build:backend-fast": "cd backend && npm install && npm run build-fast && cd .. && npm install ./backend/trackaudio-afv-1.0.0.tgz" }, @@ -31,9 +32,11 @@ "bootstrap-typeahead": "^2.3.2", "clsx": "^2.1.1", "electron-store": "^8.2.0", + "electron-updater": "^6.3.9", "react": "^18.3.1", "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", + "react-responsive": "^10.0.0", "scss": "^0.2.4", "trackaudio-afv": "file:backend/trackaudio-afv-1.0.0.tgz", "use-debounce": "^10.0.1", @@ -50,7 +53,7 @@ "@types/react": "^18.3.3", "@vitejs/plugin-react": "^4.0.0", "electron": "^32.0.2", - "electron-builder": "24.13.3", + "electron-builder": "^24.13.3", "electron-vite": "^2.0.0", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.2", diff --git a/src/main/config.ts b/src/main/config.ts index cb8f491d..3cae1b93 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -5,7 +5,7 @@ import { AlwaysOnTopMode, Configuration, RadioEffects } from '../shared/config.t // Used to check for older settings that need upgrading. This should get // increased any time the Configuration object has a breaking change. -export const currentSettingsVersion = 2; +export const currentSettingsVersion = 3; // Default application configuration. Used as a fallback when any of the properties // are missing from the saved configuration. @@ -21,7 +21,9 @@ export const defaultConfiguration = { radioEffects: 'on' as RadioEffects, hardwareType: 0, radioGain: 0, - alwaysOnTop: 'never' as AlwaysOnTopMode + alwaysOnTop: 'never' as AlwaysOnTopMode, + showExpandedRx: false, + transparentMiniMode: false }; class ConfigManager { @@ -134,34 +136,32 @@ class ConfigManager { */ private V1ToV2(config: Configuration) { // Don't migrate v2 or newer configs. - if (config.version && config.version >= 2) { - return config; + if (!config.version || config.version < 2) { + // If the audio api isn't set then it gets wiped out so the user is forced to reset + // the audio settings. + if (config.audioApi !== -1) { + config.audioApi = defaultConfiguration.audioApi; + config.audioInputDeviceId = defaultConfiguration.audioInputDeviceId; + config.headsetOutputDeviceId = defaultConfiguration.headsetOutputDeviceId; + config.speakerOutputDeviceId = defaultConfiguration.speakerOutputDeviceId; + + dialog.showMessageBoxSync({ + type: 'warning', + message: + 'Your audio settings have been reset. Please re-configure your audio devices in the settings.', + buttons: ['OK'] + }); + } + + // Upgrade the alwaysOnTop property from yes/no to the three mode version + if (typeof config.alwaysOnTop === 'boolean') { + config.alwaysOnTop ? (config.alwaysOnTop = 'always') : (config.alwaysOnTop = 'never'); + } + + // Migration complete + config.version = 2; } - // If the audio api isn't set then it gets wiped out so the user is forced to reset - // the audio settings. - if (config.audioApi !== -1) { - config.audioApi = defaultConfiguration.audioApi; - config.audioInputDeviceId = defaultConfiguration.audioInputDeviceId; - config.headsetOutputDeviceId = defaultConfiguration.headsetOutputDeviceId; - config.speakerOutputDeviceId = defaultConfiguration.speakerOutputDeviceId; - - dialog.showMessageBoxSync({ - type: 'warning', - message: - 'Your audio settings have been reset. Please re-configure your audio devices in the settings.', - buttons: ['OK'] - }); - } - - // Upgrade the alwaysOnTop property from yes/no to the three mode version - if (typeof config.alwaysOnTop === 'boolean') { - config.alwaysOnTop ? (config.alwaysOnTop = 'always') : (config.alwaysOnTop = 'never'); - } - - // Migration complete - config.version = 2; - return config; } diff --git a/src/main/index.ts b/src/main/index.ts index 723d43ae..3522144a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,19 @@ -import { app, BrowserWindow, dialog, ipcMain, Rectangle, screen, shell } from 'electron'; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Rectangle, + screen, + shell, + systemPreferences +} from 'electron'; import { electronApp, is, optimizer } from '@electron-toolkit/utils'; import Store from 'electron-store'; import { join } from 'path'; import { AfvEventTypes, TrackAudioAfv } from 'trackaudio-afv'; import icon from '../../resources/AppIcon/icon.png?asset'; +import updater from 'electron-updater'; import configManager from './config'; import { AlwaysOnTopMode, RadioEffects } from '../shared/config.type'; @@ -14,8 +24,8 @@ let version: string; let mainWindow: BrowserWindow; const defaultWindowSize = { width: 800, height: 660 }; -const miniModeWidthBreakpoint = 330; // This must match the value for $mini-mode-width-breakpoint in variables.scss. -const defaultMiniModeWidth = 300; // Default width to use for mini mode if the user hasn't explicitly resized it to something else. +const miniModeWidthBreakpoint = 455; // This must match the value for $mini-mode-width-breakpoint in variables.scss. +const defaultMiniModeWidth = 250; // Default width to use for mini mode if the user hasn't explicitly resized it to something else. // This flag is set to true if the settings dialog should be shown automatically on launch. // This happens when either there's no prior saved config, or the saved config had its audio @@ -75,11 +85,21 @@ const saveWindowBounds = () => { * mode requested. * @param mode The size to restore to: mini or maxi. */ -const restoreWindowBounds = (mode: WindowMode) => { +const restoreWindowBounds = (mode: WindowMode, numOfRadios = 0) => { + const miniModeHeight = (numOfRadios > 1 ? 22 : 33) + 24 * (numOfRadios === 0 ? 1 : numOfRadios); + const miniModeHeightMin = 22 + 24 * (numOfRadios === 0 ? 1 : numOfRadios); + const savedBounds = mode === 'maxi' ? store.get('bounds') : store.get('miniBounds'); const boundsRectangle = savedBounds as Rectangle; + if (mode === 'mini') { + mainWindow.setMinimumSize(250, miniModeHeightMin); + } else { + mainWindow.setMinimumSize(250, 120); + } + if (savedBounds !== undefined && savedBounds !== null) { const screenArea = screen.getDisplayMatching(boundsRectangle).workArea; + if ( boundsRectangle.x > screenArea.x + screenArea.width || boundsRectangle.x < screenArea.x || @@ -87,26 +107,32 @@ const restoreWindowBounds = (mode: WindowMode) => { boundsRectangle.y > screenArea.y + screenArea.height ) { // Reset window into existing screenarea + const computedHeight = mode === 'mini' ? miniModeHeight : defaultWindowSize.height; mainWindow.setBounds({ x: 0, y: 0, width: defaultWindowSize.width, - height: defaultWindowSize.height + height: computedHeight }); } else { - mainWindow.setBounds(boundsRectangle); + const computedHeight = mode === 'mini' ? miniModeHeight : boundsRectangle.height; + mainWindow.setBounds({ + x: boundsRectangle.x, + y: boundsRectangle.y, + width: boundsRectangle.width, + height: computedHeight + }); + + mainWindow.setSize(boundsRectangle.width, computedHeight); } - } - // Covers the case where the window has never been put in mini-mode before - // and the request came from an explicit "enter mini mode action". In that - // situation just set the window size to the default mini-mode size but - // don't move it. - else if (mode === 'mini') { - mainWindow.setSize(defaultMiniModeWidth, 1); + } else if (mode === 'mini') { + // Handle first-time mini mode + mainWindow.setSize(defaultMiniModeWidth, miniModeHeight); + mainWindow.setMinimumSize(250, 42); // Set minimum size after setting initial size } }; -const toggleMiniMode = () => { +const toggleMiniMode = (numOfRadios = 0) => { // Issue 84: If the window is maximized it has to be unmaximized before // setting the window size to mini-mode otherwise nothing happens. if (mainWindow.isMaximized()) { @@ -119,8 +145,16 @@ const toggleMiniMode = () => { if (isInMiniMode()) { restoreWindowBounds('maxi'); + if (process.platform === 'darwin') { + mainWindow.setWindowButtonVisibility(true); + } } else { - restoreWindowBounds('mini'); + restoreWindowBounds('mini', numOfRadios); + mainWindow.setVibrancy('fullscreen-ui'); + mainWindow.setBackgroundMaterial('mica'); + if (process.platform === 'darwin') { + mainWindow.setWindowButtonVisibility(false); + } } }; @@ -129,18 +163,27 @@ const createWindow = (): void => { TrackAudioAfv.SetCid(configManager.config.cid || ''); TrackAudioAfv.SetRadioGain(configManager.config.radioGain || 0.5); - // Create the browser window. - mainWindow = new BrowserWindow({ + const options: Electron.BrowserWindowConstructorOptions = { height: defaultWindowSize.height, width: defaultWindowSize.width, - minWidth: 210, + minWidth: 250, minHeight: 120, icon, + trafficLightPosition: { x: 12, y: 10 }, + titleBarStyle: 'hidden', webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false } - }); + }; + + if (configManager.config.transparentMiniMode) { + options.vibrancy = 'fullscreen-ui'; + options.backgroundMaterial = 'acrylic'; + } + + // Create the browser window. + mainWindow = new BrowserWindow(options); setAlwaysOnTop(configManager.config.alwaysOnTop === 'always' || false); @@ -221,6 +264,41 @@ const createWindow = (): void => { } } }); + + mainWindow.on('enter-full-screen', () => { + mainWindow.webContents.send('is-window-fullscreen', true); + }); + + mainWindow.on('leave-full-screen', () => { + mainWindow.webContents.send('is-window-fullscreen', false); + }); + + mainWindow.on('maximize', () => { + mainWindow.webContents.send('is-window-maximised', true); + }); + + mainWindow.on('unmaximize', () => { + mainWindow.webContents.send('is-window-maximised', false); + }); + + updater.autoUpdater.on('checking-for-update', () => { + mainWindow.webContents.send('check-for-updates'); + }); + updater.autoUpdater.on('update-available', (info) => { + mainWindow.webContents.send('update-available', info); + }); + updater.autoUpdater.on('update-not-available', () => { + mainWindow.webContents.send('update-not-available'); + }); + updater.autoUpdater.on('error', (err) => { + mainWindow.webContents.send('update-error', err); + }); + updater.autoUpdater.on('download-progress', (progressObj) => { + mainWindow.webContents.send('update-download-progress', progressObj); + }); + updater.autoUpdater.on('update-downloaded', (info) => { + mainWindow.webContents.send('update-downloaded', info); + }); }; // This method will be called when Electron has finished @@ -319,6 +397,16 @@ ipcMain.on('set-always-on-top', (_, alwaysOnTop: AlwaysOnTopMode) => { configManager.updateConfig({ alwaysOnTop }); }); +ipcMain.on('set-show-expanded-rx', (_, showExpandedRx: boolean) => { + configManager.updateConfig({ showExpandedRx }); +}); + +ipcMain.on('set-transparent-mini-mode', (_, transparentMiniMode: boolean) => { + configManager.updateConfig({ transparentMiniMode }); + mainWindow.setVibrancy(transparentMiniMode ? 'fullscreen-ui' : null); + mainWindow.setBackgroundMaterial('none'); +}); + ipcMain.handle('audio-get-apis', () => { return TrackAudioAfv.GetAudioApis(); }); @@ -363,8 +451,8 @@ ipcMain.handle('set-audio-api', (_, audioApi: number) => { configManager.updateConfig({ audioApi }); }); -ipcMain.handle('toggle-mini-mode', () => { - toggleMiniMode(); +ipcMain.handle('toggle-mini-mode', (_, numberOfRadios: number) => { + toggleMiniMode(numberOfRadios); }); // @@ -475,6 +563,46 @@ ipcMain.handle('close-me', () => { mainWindow.close(); }); +ipcMain.handle('restart', () => { + if (TrackAudioAfv.IsConnected()) { + TrackAudioAfv.Disconnect(); + } + + TrackAudioAfv.Exit(); + + mainWindow.close(); + createWindow(); +}); + +ipcMain.on('check-for-updates', (event) => { + if (process.platform === 'win32') { + event.reply('update-not-available'); + return; + } + + if (app.isPackaged) { + updater.autoUpdater.autoInstallOnAppQuit = false; + updater.autoUpdater.checkForUpdatesAndNotify().catch(() => { + console.error(`Error checking for updates`); + }); + } else { + event.reply('update-not-available'); + } +}); + +ipcMain.on('quit-and-install', () => { + // First disconnect TrackAudioAfv if connected + if (TrackAudioAfv.IsConnected()) { + TrackAudioAfv.Disconnect(); + } + + // Call Exit to clean up resources + TrackAudioAfv.Exit(); + + // Then perform the update + updater.autoUpdater.quitAndInstall(); +}); + ipcMain.handle( 'dialog', ( @@ -497,6 +625,38 @@ ipcMain.handle('get-version', () => { return version; }); +ipcMain.on('maximise-window', () => { + mainWindow.maximize(); +}); + +ipcMain.on('unmaximise-window', () => { + mainWindow.unmaximize(); +}); + +ipcMain.on('minimise-window', () => { + mainWindow.minimize(); +}); + +ipcMain.on('set-minimum-size', (_, width: number, height: number) => { + mainWindow.setMinimumSize(width, height); +}); + +ipcMain.on('set-window-button-visibility', (_, status: boolean) => { + mainWindow.setWindowButtonVisibility(status); +}); + +ipcMain.on('close-window', () => { + mainWindow.close(); +}); + +ipcMain.on('is-window-fullscreen', () => { + mainWindow.webContents.send('is-window-fullscreen', mainWindow.isFullScreen()); +}); + +ipcMain.handle('is-trusted-accessibility', () => { + return systemPreferences.isTrustedAccessibilityClient(true); +}); + // // Callbacks // diff --git a/src/preload/bindings.ts b/src/preload/bindings.ts index e4d107cb..40f52e53 100644 --- a/src/preload/bindings.ts +++ b/src/preload/bindings.ts @@ -1,7 +1,7 @@ import { ipcRenderer, IpcRendererEvent } from 'electron'; import { AlwaysOnTopMode, RadioEffects } from '../shared/config.type'; - +import { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from 'electron-updater'; export const api = { /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -12,6 +12,18 @@ export const api = { listener(...args); }); }, + + onIpc(channel: string, func: (data: T) => void): () => void { + const subscription = (_event: IpcRendererEvent, args: unknown): void => { + func(args as T); + }; + + ipcRenderer.on(channel, subscription); + + return () => { + ipcRenderer.removeListener(channel, subscription); + }; + }, removeAllListeners: (channel: string) => { ipcRenderer.removeAllListeners(channel); }, @@ -19,6 +31,12 @@ export const api = { setAlwaysOnTop: (state: AlwaysOnTopMode) => { ipcRenderer.send('set-always-on-top', state); }, + setShowExpandedRx: (state: boolean) => { + ipcRenderer.send('set-show-expanded-rx', state); + }, + setTransparentMiniMode: (state: boolean) => { + ipcRenderer.send('set-transparent-mini-mode', state); + }, getAudioApis: () => ipcRenderer.invoke('audio-get-apis'), getAudioInputDevices: (apiId: number) => ipcRenderer.invoke('audio-get-input-devices', apiId), getAudioOutputDevices: (apiId: number) => ipcRenderer.invoke('audio-get-output-devices', apiId), @@ -83,10 +101,12 @@ export const api = { UpdatePlatform: () => ipcRenderer.invoke('update-platform'), CloseMe: () => ipcRenderer.invoke('close-me'), + Restart: () => ipcRenderer.invoke('restart'), RequestPttKeyName: (pttIndex: number) => ipcRenderer.invoke('request-ptt-key-name', pttIndex), - toggleMiniMode: () => ipcRenderer.invoke('toggle-mini-mode'), + toggleMiniMode: (numberOfRadios: number) => + ipcRenderer.invoke('toggle-mini-mode', numberOfRadios), dialog: ( type: 'none' | 'info' | 'error' | 'question' | 'warning', @@ -95,7 +115,102 @@ export const api = { buttons: string[] ) => ipcRenderer.invoke('dialog', type, title, message, buttons), - settingsReady: () => ipcRenderer.invoke('settings-ready') + settingsReady: () => ipcRenderer.invoke('settings-ready'), + + isTrustedAccessibility(): Promise { + return ipcRenderer.invoke('is-trusted-accessibility') as Promise; + }, + + updater: { + checkForUpdates(): Promise { + return new Promise((resolve) => { + ipcRenderer.once('check-for-updates', () => { + resolve(); + }); + ipcRenderer.send('check-for-updates'); + }); + }, + + onCheckingForUpdate(callback: () => void): () => void { + return api.onIpc('check-for-updates', () => { + callback(); + }); + }, + + quitAndInstall(): void { + ipcRenderer.send('quit-and-install'); + }, + + onUpdateAvailable(): Promise { + return new Promise((resolve) => { + ipcRenderer.once('update-available', (_event, info: UpdateInfo) => { + resolve(info); + }); + }); + }, + + onUpdateNotAvailable(): Promise { + return new Promise((resolve) => { + ipcRenderer.once('update-not-available', () => { + resolve(); + }); + }); + }, + + onUpdateError(callback: (error: Error) => void): () => void { + return api.onIpc('update-error', (error) => { + callback(error); + }); + }, + + onUpdateDownloadProgress(callback: (progressObj: ProgressInfo) => void): () => void { + return api.onIpc('update-download-progress', (info) => { + callback(info); + }); + }, + + onUpdateDownloaded(): Promise { + return new Promise((resolve) => { + ipcRenderer.once('update-downloaded', (_event, info: UpdateDownloadedEvent) => { + resolve(info); + }); + }); + } + }, + + window: { + checkIsFullscreen(): void { + ipcRenderer.send('is-window-fullscreen'); + }, + minimise: (): void => { + ipcRenderer.send('minimise-window'); + }, + maximise: (): void => { + ipcRenderer.send('maximise-window'); + }, + unmaximise: (): void => { + ipcRenderer.send('unmaximise-window'); + }, + close: (): void => { + ipcRenderer.send('close-window'); + }, + isFullScreen: (callback: (status: boolean) => void): (() => void) => { + return api.onIpc('is-window-fullscreen', (data) => { + callback(data); + }); + }, + isMaximised: (callback: (status: boolean) => void): (() => void) => { + return api.onIpc('is-window-maximised', (data) => { + callback(data); + }); + }, + setMinimumSize: (width: number, height: number): void => { + ipcRenderer.send('set-minimum-size', width, height); + }, + setWindowButtonVisibility: (status: boolean): void => { + ipcRenderer.send('set-window-button-visibility', status); + } + } }; export type API = typeof api; diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 60067611..6c949316 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -1,7 +1,6 @@ import { createRoot } from 'react-dom/client'; import Navbar from './components/navbar'; import RadioContainer from './components/radio/radio-container'; -import Sidebar from './components/sidebar/sidebar'; import ErrorDialog from './components/error'; import Bootstrap from './components/bootstrap'; @@ -9,8 +8,23 @@ import Mini from './components/mini'; import './index.scss'; import './style/app.scss'; +import FocusBar from './components/focusBar'; +// import { useState } from 'react'; +// import Updater from './components/updater/Updater'; function App() { + // const [updateCheckDone, setUpdateCheckDone] = useState(false); + + // if (!updateCheckDone) { + // return ( + // { + // setUpdateCheckDone(true); + // }} + // /> + // ); + // } + return ( <> @@ -18,8 +32,11 @@ function App() {
- - +
+ + +
+ {/* */}
); diff --git a/src/renderer/src/components/MiniModeToggleButton.tsx b/src/renderer/src/components/MiniModeToggleButton.tsx index 3e1004ac..33906aef 100644 --- a/src/renderer/src/components/MiniModeToggleButton.tsx +++ b/src/renderer/src/components/MiniModeToggleButton.tsx @@ -1,22 +1,32 @@ +import useRadioState from '@renderer/store/radioStore'; +import useSessionStore from '@renderer/store/sessionStore'; import React, { useCallback } from 'react'; import { Fullscreen, FullscreenExit } from 'react-bootstrap-icons'; interface MiniModeToggleButtonProps { showRestoreButton: boolean; + alwaysEnabled?: boolean; } -const MiniModeToggleButton: React.FC = ({ showRestoreButton }) => { +const MiniModeToggleButton: React.FC = ({ + showRestoreButton, + alwaysEnabled +}) => { + const [radios] = useRadioState((state) => [state.radios]); + const [isConnected] = useSessionStore((state) => [state.isConnected]); const toggleMiniMode = useCallback(() => { - window.api.toggleMiniMode().catch((error: unknown) => { + if (!isConnected && !alwaysEnabled) return; + window.api.toggleMiniMode(radios.filter((r) => r.rx).length).catch((error: unknown) => { console.error(error); }); - }, []); + }, [radios, isConnected]); return ( + + + + + + ); +}; + +export default AddStationModal; diff --git a/src/renderer/src/components/bootstrap.tsx b/src/renderer/src/components/bootstrap.tsx index 8b4416c7..2abed1d2 100644 --- a/src/renderer/src/components/bootstrap.tsx +++ b/src/renderer/src/components/bootstrap.tsx @@ -4,12 +4,25 @@ import useErrorStore from '../store/errorStore'; import useSessionStore from '../store/sessionStore'; import useUtilStore from '../store/utilStore'; import { StationStateUpdate } from '../interfaces/StationStateUpdate'; +import { Configuration } from 'src/shared/config.type'; const Bootsrap: React.FC = () => { useEffect(() => { void window.api.RequestPttKeyName(1); void window.api.RequestPttKeyName(2); + window.api.window.checkIsFullscreen(); + + window.api + .getConfig() + .then((config: Configuration) => { + useUtilStore.getState().setShowExpandedRxInfo(config.showExpandedRx); + useUtilStore.getState().setTransparentMiniMode(config.transparentMiniMode); + }) + .catch((err: unknown) => { + console.error(err); + }); + window.api.on('VuMeter', (vu: string, peakVu: string) => { const vuFloat = Math.abs(parseFloat(vu)); const peakVuFloat = Math.abs(parseFloat(peakVu)); @@ -110,6 +123,7 @@ const Bootsrap: React.FC = () => { useSessionStore.getState().setIsConnecting(false); useSessionStore.getState().setIsConnected(false); useRadioState.getState().reset(); + useUtilStore.getState().setIsEditMode(false); }); window.api.on('network-connected', (callsign: string, dataString: string) => { diff --git a/src/renderer/src/components/clock.tsx b/src/renderer/src/components/clock.tsx index b75de227..8cb8a61a 100644 --- a/src/renderer/src/components/clock.tsx +++ b/src/renderer/src/components/clock.tsx @@ -1,26 +1,45 @@ +import useUtilStore from '@renderer/store/utilStore'; import React, { useEffect, useState } from 'react'; const Clock: React.FC = () => { - const [time, setTime] = useState('XX:XX:XXZ'); - + const [time, setLocalTime] = useState('XX:XX:XXZ'); + const [setTime] = useUtilStore((state) => [state.setTime]); useEffect(() => { - setInterval(() => { - const dateObject = new Date(); + const formatTime = (dateObject: Date): string => { + const hour = dateObject.getUTCHours().toString().padStart(2, '0'); + const minute = dateObject.getUTCMinutes().toString().padStart(2, '0'); + const second = dateObject.getUTCSeconds().toString().padStart(2, '0'); + return `${hour}:${minute}:${second}Z`; + }; + + setLocalTime(formatTime(new Date())); + + const now = new Date(); + const msUntilNextSecond = 1000 - now.getMilliseconds(); - const hour = dateObject.getUTCHours().toString(); - const minute = dateObject.getUTCMinutes().toString(); - const second = dateObject.getUTCSeconds().toString(); + const initialTimeoutId = setTimeout(() => { + setLocalTime(formatTime(new Date())); + setTime(new Date()); - const currentTime = - hour.padStart(2, '0') + ':' + minute.padStart(2, '0') + ':' + second.padStart(2, '0') + 'Z'; + const intervalId = setInterval(() => { + setLocalTime(formatTime(new Date())); + setTime(new Date()); + }, 1000); - setTime(currentTime); - }, 1000); + return () => { + clearInterval(intervalId); + }; + }, msUntilNextSecond); + + return () => { + clearTimeout(initialTimeoutId); + }; }, []); + return ( - <> -
{time}
- +
+
{time}
+
); }; diff --git a/src/renderer/src/components/connect-timer.tsx b/src/renderer/src/components/connect-timer.tsx new file mode 100644 index 00000000..4a16aee9 --- /dev/null +++ b/src/renderer/src/components/connect-timer.tsx @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react'; +import useSessionStore from '@renderer/store/sessionStore'; +import useUtilStore from '@renderer/store/utilStore'; + +const ConnectTimer = () => { + const connectTimestamp = useSessionStore((state) => state.connectTimestamp); + const [elapsedTime, setElapsedTime] = useState(null); + const [time] = useUtilStore((state) => [state.time]); + + useEffect(() => { + if (!connectTimestamp) return; + const difference = Number(time) - Number(connectTimestamp); + + const hours = Math.floor(difference / (1000 * 60 * 60)); + const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((difference % (1000 * 60)) / 1000); + + if (hours < 0 || minutes < 0 || seconds < 0) { + setElapsedTime(null); + return; + } + + const formattedTime = + `${hours.toString().padStart(2, '0')}:` + + `${minutes.toString().padStart(2, '0')}:` + + seconds.toString().padStart(2, '0'); + + setElapsedTime(formattedTime); + }, [time]); + + if (!elapsedTime) { + return; + } + + return Connected: {elapsedTime}; +}; + +export default ConnectTimer; diff --git a/src/renderer/src/components/delete-multiple-radios.tsx b/src/renderer/src/components/delete-multiple-radios.tsx new file mode 100644 index 00000000..cb676599 --- /dev/null +++ b/src/renderer/src/components/delete-multiple-radios.tsx @@ -0,0 +1,76 @@ +import useRadioState from '@renderer/store/radioStore'; +import useSessionStore from '@renderer/store/sessionStore'; +import useUtilStore from '@renderer/store/utilStore'; +import React from 'react'; +import { TrashFill } from 'react-bootstrap-icons'; + +const DeleteMultipleRadios: React.FC = () => { + const [isConnected] = useSessionStore((state) => [state.isConnected]); + const [radiosToBeDeleted, radios, removeRadio, setPendingDeletion] = useRadioState((state) => [ + state.radiosSelected, + state.radios, + state.removeRadio, + state.setPendingDeletion + ]); + + const [setIsEditMode] = useUtilStore((state) => [state.setIsEditMode]); + + const handleDeleteRadios = () => { + if (radiosToBeDeleted.length == 0) { + radios.forEach((radio) => { + if (radio.callsign !== 'UNICOM' && radio.callsign !== 'GUARD') { + setPendingDeletion(radio.frequency, false); + awaitEndOfRxForDeletion(radio.frequency); + } + }); + } else { + radiosToBeDeleted.forEach((radio) => { + setPendingDeletion(radio.frequency, true); + awaitEndOfRxForDeletion(radio.frequency); + }); + } + setIsEditMode(false); + }; + + const awaitEndOfRxForDeletion = (frequency: number): void => { + const interval = setInterval( + (frequency: number) => { + const radio = useRadioState.getState().radios.find((r) => r.frequency === frequency); + if (!radio) { + clearInterval(interval); + return; + } + + if (!radio.currentlyRx && !radio.currentlyTx) { + void window.api.removeFrequency(radio.frequency); + removeRadio(radio.frequency); + clearInterval(interval); + } + }, + 60, + frequency + ); + + // Clear the interval after 5 seconds + setTimeout(() => { + clearInterval(interval); + }, 10000); + }; + + return ( +
+ +
+ ); +}; + +export default DeleteMultipleRadios; diff --git a/src/renderer/src/components/error.tsx b/src/renderer/src/components/error.tsx index 1c1c564c..bd5d4c9d 100644 --- a/src/renderer/src/components/error.tsx +++ b/src/renderer/src/components/error.tsx @@ -5,9 +5,11 @@ import useSound from 'use-sound'; // @ts-expect-error idk this is weird import errorSfx from '../assets/md80_error.mp3'; +import { useMediaQuery } from 'react-responsive'; const ErrorDialog: React.FC = () => { const errorStore = useErrorStore((state) => state); + const isMiniMode = useMediaQuery({ maxWidth: '455px' }); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const [play] = useSound(errorSfx); @@ -22,6 +24,10 @@ const ErrorDialog: React.FC = () => { return null; } + if (isMiniMode) { + return; + } + return (
diff --git a/src/renderer/src/components/focusBar.tsx b/src/renderer/src/components/focusBar.tsx new file mode 100644 index 00000000..96c11f42 --- /dev/null +++ b/src/renderer/src/components/focusBar.tsx @@ -0,0 +1,96 @@ +import useSessionStore from '@renderer/store/sessionStore'; +import RadioStatus from './sidebar/radio-status'; +import useUtilStore from '@renderer/store/utilStore'; +import ConnectTimer from './connect-timer'; +import { useMediaQuery } from 'react-responsive'; +import clsx from 'clsx'; + +const FocusBar = () => { + const [version, isConnected, connectTimestamp] = useSessionStore((state) => [ + state.version, + state.isConnected, + state.connectTimestamp + ]); + const [pendingRestart] = useUtilStore((state) => [state.pendingRestart]); + const isWideScreen = useMediaQuery({ minWidth: '740px' }); + const isSmallScreen = useMediaQuery({ maxWidth: '490px' }); + + const restartApp = () => { + if (isConnected) { + return; + } + window.api.Restart().catch((error: unknown) => { + console.error(error); + }); + }; + + return ( +
+
+
+ {pendingRestart && !isConnected ? ( + + ) : ( + connectTimestamp && + isWideScreen && ( +
+ +
+ ) + )} + + {/* Center Radio Status */} + {isWideScreen ? ( +
+
+ +
+
+ ) : ( +
+ +
+ )} + + {!isSmallScreen && ( +
+
+ {version} |  + + Licenses + +
+
+ )} +
+
+
+ ); +}; + +export default FocusBar; diff --git a/src/renderer/src/components/mini.tsx b/src/renderer/src/components/mini.tsx index 41dbfb79..e7d9c577 100644 --- a/src/renderer/src/components/mini.tsx +++ b/src/renderer/src/components/mini.tsx @@ -1,14 +1,65 @@ import React, { useState } from 'react'; import useRadioState from '../store/radioStore'; import MiniModeToggleButton from './MiniModeToggleButton'; +import useMiniModeManager from '@renderer/helpers/useMiniModeManager'; const Mini: React.FC = () => { const [radios] = useRadioState((state) => [state.radios]); const [isHovered, setIsHovered] = useState(false); + const { isConnected } = useMiniModeManager(); + + if (!isConnected) { + return ( +
{ + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + > +
+
+ Disconnected +
+
+ {/* Make only the button container no-drag */} +
+ +
+
+ ); + } + + if (radios.filter((r) => r.rx).length === 0) { + return ( +
{ + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + > +
+
+ No RX radios +
+
+ {/* Make only the button container no-drag */} +
+ +
+
+ ); + } + return (
{ setIsHovered(true); }} @@ -21,19 +72,22 @@ const Mini: React.FC = () => { .filter((r) => r.rx) .map((radio) => { return ( -
+
- {radio.callsign !== 'MANUAL' ? radio.callsign : radio.humanFrequency} + {radio.callsign !== 'MANUAL' ? radio.callsign : radio.humanFrequency}: - :{' '} - - {radio.lastReceivedCallsign ? radio.lastReceivedCallsign : ''} + + {radio.lastReceivedCallsign ? radio.lastReceivedCallsign : '--------'}
); })}
-
+ {/* Make only the button container no-drag */} +
diff --git a/src/renderer/src/components/navbar.tsx b/src/renderer/src/components/navbar.tsx index 0f0fb5af..4813216e 100644 --- a/src/renderer/src/components/navbar.tsx +++ b/src/renderer/src/components/navbar.tsx @@ -1,44 +1,35 @@ import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; -import { checkIfCallsignIsRelief, getCleanCallsign } from '../helpers/CallsignHelper'; -import useErrorStore from '../store/errorStore'; import useSessionStore from '../store/sessionStore'; -import useUtilStore from '../store/utilStore'; import '../style/navbar.scss'; import Clock from './clock'; import MiniModeToggleButton from './MiniModeToggleButton'; import SettingsModal from './settings-modal/settings-modal'; -import { Configuration } from 'src/shared/config.type'; +import TitleBar from './titlebar/TitleBar'; +import { GearFill, PencilSquare, PlusCircleFill } from 'react-bootstrap-icons'; +import SessionStatus from './titlebar/session-status/SessionStatus'; +import useUtilStore from '@renderer/store/utilStore'; +import AddStationModal from './add-station-model/station-modal'; +import DeleteMultipleRadios from './delete-multiple-radios'; +import useRadioState from '@renderer/store/radioStore'; +import RefreshMultipleRadios from './refresh-multiple-radios'; const Navbar: React.FC = () => { - const [showModal, setShowModal] = useState(false); - const [platform] = useUtilStore((state) => [state.platform]); - - const postError = useErrorStore((state) => state.postError); - const [ - isConnected, - isConnecting, - setIsConnecting, - setIsConnected, - callsign, - isNetworkConnected, - radioGain, - setRadioGain, - isAtc, - setStationCallsign - ] = useSessionStore((state) => [ - state.isConnected, - state.isConnecting, - state.setIsConnecting, - state.setIsConnected, + const [showSettingsModal, setShowSettingsModal] = useState(false); + const [showAddStationModal, setShowAddStationModal] = useState(false); + const [platform, isEditMode, setIsEditMode] = useUtilStore((state) => [ + state.platform, + state.isEditMode, + state.setIsEditMode + ]); + const [callsign, isConnected, isConnecting] = useSessionStore((state) => [ state.callsign, - state.isNetworkConnected, - state.radioGain, - state.setRadioGain, - state.isAtc, - state.setStationCallsign + state.isConnected, + state.isConnecting ]); + const [clearRadiosToBeDeleted] = useRadioState((state) => [state.clearRadiosToBeDeleted]); + // Handles letting the main process know settings can be triggered // remotely, and responds to requests to open the settings dialog. useEffect(() => { @@ -47,174 +38,125 @@ const Navbar: React.FC = () => { }); window.electron.ipcRenderer.on('show-settings', () => { - setShowModal(true); + if (showAddStationModal) return; + setShowSettingsModal(true); }); }, []); - useEffect(() => { - window.api - .getConfig() - .then((config: Configuration) => { - const gain = config.radioGain || 0.5; - const UiGain = gain * 100 || 50; - - window.api - .SetRadioGain(gain) - .then(() => { - setRadioGain(UiGain); - }) - .catch((err: unknown) => { - console.error(err); - }); - }) - .catch((err: unknown) => { - console.error(err); - }); - }, [setRadioGain]); - - const doConnect = () => { - setIsConnecting(true); - window.api - .connect() - .then((ret) => { - if (!ret) { - postError('Error connecting to AFV, check your configuration and credentials.'); - setIsConnecting(false); - setIsConnected(false); - } - }) - .catch((err: unknown) => { - console.error(err); - }); - }; - - const handleConnectDisconnect = () => { - if (isConnected) { - void window.api.disconnect(); - return; - } - - if (!isNetworkConnected) { - return; - } - - if (checkIfCallsignIsRelief(callsign) && isAtc) { - const reliefCallsign = getCleanCallsign(callsign); - window.api - .dialog( - 'question', - 'Relief callsign detected', - 'You might be using a relief callsign, please select which callsign you want to use.', - [callsign, reliefCallsign] - ) - .then((ret) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (ret.response === 0) { - setStationCallsign(callsign); - } else { - setStationCallsign(reliefCallsign); - } - }) - .then(() => { - doConnect(); - }) - .catch((err: unknown) => { - console.error(err); - }); - } else { - setStationCallsign(callsign); - doConnect(); - } - }; - - const updateRadioGainValue = (newGain: number) => { - window.api - .SetRadioGain(newGain / 100) - .then(() => { - setRadioGain(newGain); - }) - .catch((err: unknown) => { - console.error(err); - }); - }; - - const handleRadioGainChange = (event: React.ChangeEvent) => { - updateRadioGainValue(event.target.valueAsNumber); - }; - - const handleRadioGainMouseWheel = (event: React.WheelEvent) => { - const newValue = Math.min(Math.max(radioGain + (event.deltaY > 0 ? -1 : 1), 0), 100); - - updateRadioGainValue(newValue); - }; - return ( <> -
- - + + + + + +
+ +
+
+ {isEditMode && ( + + + )} - > - {isNetworkConnected ? callsign : 'Not Connected'} -
- - +
+ + )} + {!isEditMode && ( + +
+ +
+
+ )} + + + + + {isConnected && callsign + ? `Connected as ${callsign}` + : callsign + ? callsign + : 'Track Audio'} + + + + + {/* {isNetworkConnected && ( */} + +
+ + {platform === 'linux' && ( + + )} +
+
+ {/* )} */} + {/* {isNetworkConnected && ( */} + + + + {/* )} */} +
+ + + {showSettingsModal && ( + { + setShowSettingsModal(false); }} - > - Settings - + /> + )} - - Gain: {radioGain.toFixed(0).padStart(3, '0')}% - - - - {platform === 'linux' && ( - - )} -
- {showModal && ( - { - setShowModal(false); + setShowAddStationModal(false); }} /> )} diff --git a/src/renderer/src/components/radio/connection-steps.tsx b/src/renderer/src/components/radio/connection-steps.tsx new file mode 100644 index 00000000..b2a005bf --- /dev/null +++ b/src/renderer/src/components/radio/connection-steps.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { CheckCircleFill, Gear, Link45deg, Icon, Wifi } from 'react-bootstrap-icons'; + +interface Step { + id: number; + title: string; + description: string; + icon: Icon; + isCompleted: boolean; +} + +interface ConnectionStepsProps { + isNetworkConnected: boolean; + isConnected: boolean; +} + +const ConnectionSteps: React.FC = ({ isNetworkConnected, isConnected }) => { + const steps: Step[] = [ + { + id: 1, + title: 'Network Connection', + description: 'Check your network connectivity', + icon: Wifi, + isCompleted: isNetworkConnected + }, + { + id: 2, + title: 'Ready to Connect', + description: 'System is ready for connection', + icon: Gear, + isCompleted: isNetworkConnected && !isConnected + }, + { + id: 3, + title: 'Connect to Simulator', + description: 'Establish simulator connection', + icon: Link45deg, + isCompleted: isConnected + } + ]; + + return ( +
+
+ {steps.map((step, index) => ( +
+ {/* Connector Line */} + {index < steps.length - 1 && ( +
+ )} + + {/* Step Item */} +
+ {/* Icon Circle */} +
+ {step.isCompleted ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+
+ {step.title} +
+

{step.description}

+
+
+
+ ))} +
+
+ ); +}; + +export default ConnectionSteps; diff --git a/src/renderer/src/components/radio/expanded-rx-info.tsx b/src/renderer/src/components/radio/expanded-rx-info.tsx new file mode 100644 index 00000000..681eb475 --- /dev/null +++ b/src/renderer/src/components/radio/expanded-rx-info.tsx @@ -0,0 +1,52 @@ +import useRadioState from '@renderer/store/radioStore'; +import React, { useMemo } from 'react'; + +const ExpandedRxInfo: React.FC = () => { + const radios = useRadioState((state) => state.radios); + + const radiosWithRx = useMemo(() => { + return radios.filter((radio) => radio.rx); + }, [radios]); + + return ( +
+
RX LIST
+ {radiosWithRx.map((radio) => ( +
+
+ {radio.callsign}: +
+
+ {radio.lastReceivedCallsign ? ( + +
+ {radio.lastReceivedCallsign} +
+
+ ) : ( + +
--------
+
+ )} +
+
+ ))} +
+ ); +}; + +export default ExpandedRxInfo; diff --git a/src/renderer/src/components/radio/global-radio-gain.tsx b/src/renderer/src/components/radio/global-radio-gain.tsx new file mode 100644 index 00000000..6214abbf --- /dev/null +++ b/src/renderer/src/components/radio/global-radio-gain.tsx @@ -0,0 +1,94 @@ +import useSessionStore from '@renderer/store/sessionStore'; +import '../../style/GlobalRadio.scss'; +import { useEffect } from 'react'; +import { Configuration } from 'src/shared/config.type'; +import { useMediaQuery } from 'react-responsive'; +const GlobalRadioGain = () => { + const [radioGain, setRadioGain] = useSessionStore((state) => [ + state.radioGain, + state.setRadioGain + ]); + const isWideScreen = useMediaQuery({ minWidth: '895px' }); + + useEffect(() => { + window.api + .getConfig() + .then((config: Configuration) => { + const gain = config.radioGain || 0.5; + const UiGain = gain * 100 || 50; + + window.api + .SetRadioGain(gain) + .then(() => { + setRadioGain(UiGain); + }) + .catch((err: unknown) => { + console.error(err); + }); + }) + .catch((err: unknown) => { + console.error(err); + }); + }, [setRadioGain]); + + const updateRadioGainValue = (newGain: number) => { + window.api + .SetRadioGain(newGain / 100) + .then(() => { + setRadioGain(newGain); + }) + .catch((err: unknown) => { + console.error(err); + }); + }; + + const handleRadioGainChange = (event: React.ChangeEvent) => { + updateRadioGainValue(event.target.valueAsNumber); + }; + + const handleRadioGainMouseWheel = (event: React.WheelEvent) => { + const newValue = Math.min(Math.max(radioGain + (event.deltaY > 0 ? -1 : 1), 0), 100); + updateRadioGainValue(newValue); + }; + + return ( +
+ {isWideScreen && ( + +
+ MAIN +
+
+ )} +
+ +
+
+ ); +}; +export default GlobalRadioGain; diff --git a/src/renderer/src/components/radio/radio-container.tsx b/src/renderer/src/components/radio/radio-container.tsx index fdc22cf8..9bf4873b 100644 --- a/src/renderer/src/components/radio/radio-container.tsx +++ b/src/renderer/src/components/radio/radio-container.tsx @@ -1,34 +1,102 @@ import React, { useMemo } from 'react'; import Radio from './radio'; import useRadioState from '../../store/radioStore'; -import UnicomGuardBar from './unicom-guard'; +import TopBarContainer from './top-bar-container'; +import useSessionStore from '@renderer/store/sessionStore'; +import useUtilStore from '@renderer/store/utilStore'; +import ExpandedRxInfo from './expanded-rx-info'; +import { useMediaQuery } from 'react-responsive'; +import AddStation from '../sidebar/add-station'; +import AddFrequency from '../sidebar/add-frequency'; const RadioContainer: React.FC = () => { const radios = useRadioState((state) => state.radios); - + const [isConnected, isNetworkConnected] = useSessionStore((state) => [ + state.isConnected, + state.isNetworkConnected + ]); + const isWideScreen = useMediaQuery({ minWidth: '790px' }); const filteredRadios = useMemo(() => { return radios.filter( (radio) => radio.frequency !== 0 && radio.frequency !== 122.8e6 && radio.frequency !== 121.5e6 ); }, [radios]); + const [showExpandedRxInfo] = useUtilStore((state) => [state.showExpandedRxInfo]); - return ( - <> -
-
- + if (!isNetworkConnected) { + return ( +
+
+ No VATSIM connection detected!
+
+ Please ensure your ATC client is running and connected to the VATSIM network. +
+
+ ); + } -
-
- {filteredRadios.map((radio) => ( - - ))} + if (!isConnected) { + return ( +
+
+ VATSIM connection detected! +
+
+ Click the connect button to establish a connection to the VATSIM audio network. +
+
+ ); + } + + return ( +
+
+ +
+ +
+
+
+ {filteredRadios.length === 0 ? ( +
+
+
+ +
+
+ +
+
+
+ ) : ( +
+ {filteredRadios.map((radio) => ( + + ))} +
+ )}
+ + {showExpandedRxInfo && isWideScreen && ( +
+ +
+ )}
- +
); }; - export default RadioContainer; diff --git a/src/renderer/src/components/radio/radio.tsx b/src/renderer/src/components/radio/radio.tsx index 54448147..d6c02dfa 100644 --- a/src/renderer/src/components/radio/radio.tsx +++ b/src/renderer/src/components/radio/radio.tsx @@ -3,6 +3,7 @@ import useRadioState, { RadioType } from '../../store/radioStore'; import clsx from 'clsx'; import useErrorStore from '../../store/errorStore'; import useSessionStore from '../../store/sessionStore'; +import useUtilStore from '../../store/utilStore'; export interface RadioProps { radio: RadioType; @@ -10,16 +11,28 @@ export interface RadioProps { const Radio: React.FC = ({ radio }) => { const postError = useErrorStore((state) => state.postError); - const [setRadioState, selectRadio, removeRadio, setPendingDeletion] = useRadioState((state) => [ + const [ + setRadioState, + selectRadio, + removeRadio, + setPendingDeletion, + addOrRemoveRadioToBeDeleted, + radiosToBeDeleted + ] = useRadioState((state) => [ state.setRadioState, state.selectRadio, state.removeRadio, - state.setPendingDeletion + state.setPendingDeletion, + state.addOrRemoveRadioToBeDeleted, + state.radiosSelected ]); - + const [isEditMode] = useUtilStore((state) => [state.isEditMode]); const isATC = useSessionStore((state) => state.isAtc); const clickRadioHeader = () => { + if (isEditMode) { + addOrRemoveRadioToBeDeleted(radio); + } selectRadio(radio.frequency); if (radio.transceiverCount === 0 && radio.callsign !== 'MANUAL') { void window.api.RefreshStation(radio.callsign); @@ -202,12 +215,17 @@ const Radio: React.FC = ({ radio }) => { }; return ( - <> -
-
+
r.frequency === radio.frequency) && 'bg-info', + (radio.rx || radio.tx) && 'radio-active' + )} + > +
+
- - + +
+ + + +
-
+ +
+
- +
); }; diff --git a/src/renderer/src/components/radio/rxinfo.tsx b/src/renderer/src/components/radio/rxinfo.tsx new file mode 100644 index 00000000..ed36f35b --- /dev/null +++ b/src/renderer/src/components/radio/rxinfo.tsx @@ -0,0 +1,98 @@ +import useRadioState from '@renderer/store/radioStore'; +import React, { useMemo, useState, useEffect } from 'react'; +import type { RadioType } from '@renderer/store/radioStore'; +import { LayoutSidebarInsetReverse, LayoutSidebarReverse } from 'react-bootstrap-icons'; +import useUtilStore from '@renderer/store/utilStore'; +import clsx from 'clsx'; +import { useMediaQuery } from 'react-responsive'; + +const RxInfo: React.FC = () => { + const radios = useRadioState((state) => state.radios); + const [lastActiveRadio, setLastActiveRadio] = useState(null); + const [showExpandedRxInfo, setShowExpandedRxInfo] = useUtilStore((state) => [ + state.showExpandedRxInfo, + state.setShowExpandedRxInfo + ]); + const isWideScreen = useMediaQuery({ minWidth: '895px' }); + + useEffect(() => { + const currentlyReceiving = radios.find( + (radio) => radio.rx && radio.lastReceivedCallsign && radio.currentlyRx + ); + + if (currentlyReceiving) { + setLastActiveRadio(currentlyReceiving); + } + }, [radios]); + + const displayRadio = useMemo(() => { + const currentlyReceiving = radios.find( + (radio) => radio.rx && radio.lastReceivedCallsign && radio.currentlyRx + ); + return currentlyReceiving ?? lastActiveRadio; + }, [radios, lastActiveRadio]); + + const isCurrentlyReceiving = useMemo(() => { + return radios.some((radio) => radio.rx && radio.lastReceivedCallsign && radio.currentlyRx); + }, [radios]); + + return ( +
+
+
+ RX: +
+
+ {displayRadio ? ( + +
+ {displayRadio.lastReceivedCallsign} +
+
+ ) : ( + +
--------
+
+ )} +
+
+ {isWideScreen && ( +
+ +
+ )} +
+ ); + + // return ( + //
+ //
+ // {displayRadio && ( + //
+ // {displayRadio.lastReceivedCallsign} + //
+ // )} + //
+ //
+ // ); +}; + +export default RxInfo; diff --git a/src/renderer/src/components/radio/top-bar-container.tsx b/src/renderer/src/components/radio/top-bar-container.tsx new file mode 100644 index 00000000..4ddd4564 --- /dev/null +++ b/src/renderer/src/components/radio/top-bar-container.tsx @@ -0,0 +1,37 @@ +import RxInfo from './rxinfo'; +import GlobalRadioGain from './global-radio-gain'; +import UnicomGuardBar from './unicom-guard'; +import { useMediaQuery } from 'react-responsive'; + +const TopBarContainer = () => { + const isMediumScreen = useMediaQuery({ minWidth: '765px' }); + const isSmallScreen = useMediaQuery({ minWidth: '630px' }); + + return ( +
+ {/* Main container with the centered content and right-aligned text */} +
+ {/* Left-aligned element */} + {isMediumScreen && ( +
+ +
+ )} + + {/* Center element */} +
+ +
+ + {/* Right-aligned element */} + {isSmallScreen && ( +
+ +
+ )} +
+
+ ); +}; + +export default TopBarContainer; diff --git a/src/renderer/src/components/radio/unicom-guard.tsx b/src/renderer/src/components/radio/unicom-guard.tsx index 25ab264c..d6d24c61 100644 --- a/src/renderer/src/components/radio/unicom-guard.tsx +++ b/src/renderer/src/components/radio/unicom-guard.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import useSessionStore from '@renderer/store/sessionStore'; import useErrorStore from '@renderer/store/errorStore'; import { GuardFrequency, UnicomFrequency } from '../../../../shared/common'; +import { useMediaQuery } from 'react-responsive'; const UnicomGuardBar = () => { const [radios, setRadioState, addRadio, removeRadio] = useRadioState((state) => [ @@ -15,6 +16,8 @@ const UnicomGuardBar = () => { ]); const [isConnected, isAtc] = useSessionStore((state) => [state.isConnected, state.isAtc]); + const isReducedSize = useMediaQuery({ maxWidth: '895px' }); + const [localRadioGain, setLocalRadioGain] = useState(50); const postError = useErrorStore((state) => state.postError); @@ -169,7 +172,6 @@ const UnicomGuardBar = () => { console.error('Failed to add UNICOM frequency'); return; } - console.log('Adding unicom frequency'); addRadio(UnicomFrequency, 'UNICOM', 'UNICOM'); void window.api.SetFrequencyRadioGain(UnicomFrequency, localRadioGain / 100); }); @@ -178,7 +180,6 @@ const UnicomGuardBar = () => { console.error('Failed to add GUARD frequency'); return; } - console.log('Adding guard frequency'); addRadio(GuardFrequency, 'GUARD', 'GUARD'); void window.api.SetFrequencyRadioGain(GuardFrequency, localRadioGain / 100); }); @@ -219,7 +220,7 @@ const UnicomGuardBar = () => { return (
- + UNICOM - + + {!isReducedSize && ( + + VOLUME + + )} { + const [isConnected] = useSessionStore((state) => [state.isConnected]); + const [radiosSelected] = useRadioState((state) => [state.radiosSelected]); + + const [setIsEditMode] = useUtilStore((state) => [state.setIsEditMode]); + + const refreshMultipleRadios = () => { + radiosSelected.forEach((radio) => { + if (radio.callsign === 'MANUAL') { + return; + } + void window.api.RefreshStation(radio.callsign); + }); + setIsEditMode(false); + }; + + return ( +
+ +
+ ); +}; + +export default RefreshMultipleRadios; diff --git a/src/renderer/src/components/settings-modal/settings-modal.tsx b/src/renderer/src/components/settings-modal/settings-modal.tsx index 544ef081..bf21bf71 100644 --- a/src/renderer/src/components/settings-modal/settings-modal.tsx +++ b/src/renderer/src/components/settings-modal/settings-modal.tsx @@ -25,11 +25,11 @@ const SettingsModal: React.FC = ({ closeModal }) => { const [audioApis, setAudioApis] = useState(Array); const [audioOutputDevices, setAudioOutputDevices] = useState(Array); const [audioInputDevices, setAudioInputDevices] = useState(Array); - const [radioEffects, setRadioEffects] = useState("on"); + const [radioEffects, setRadioEffects] = useState('on'); const [hardwareType, setHardwareType] = useState(0); const [config, setConfig] = useState({} as Configuration); const [alwaysOnTop, setAlwaysOnTop] = useState('never'); - + const [transparentMiniMode, setLocalTransparentMiniMode] = useState(false); const [cid, setCid] = useState(''); const [password, setPassword] = useState(''); @@ -44,7 +44,11 @@ const SettingsModal: React.FC = ({ closeModal }) => { hasPtt1BeenSetDuringSetup, hasPtt2BeenSetDuringSetup, updatePtt1KeySet, - updatePtt2KeySet + updatePtt2KeySet, + showExpandedRxInfo, + setShowExpandedRxInfo, + setTransparentMiniMode, + setPendingRestart ] = useUtilStore((state) => [ state.vu, state.peakVu, @@ -54,7 +58,11 @@ const SettingsModal: React.FC = ({ closeModal }) => { state.hasPtt1BeenSetDuringSetup, state.hasPtt2BeenSetDuringSetup, state.updatePtt1KeySet, - state.updatePtt2KeySet + state.updatePtt2KeySet, + state.showExpandedRxInfo, + state.setShowExpandedRxInfo, + state.setTransparentMiniMode, + state.setPendingRestart ]); const [isMicTesting, setIsMicTesting] = useState(false); @@ -68,6 +76,9 @@ const SettingsModal: React.FC = ({ closeModal }) => { setRadioEffects(config.radioEffects); setHardwareType(config.hardwareType); setAlwaysOnTop(config.alwaysOnTop as AlwaysOnTopMode); // Type assertion since the config will never be a boolean at this point + setShowExpandedRxInfo(config.showExpandedRx); + setTransparentMiniMode(config.transparentMiniMode); + setLocalTransparentMiniMode(config.transparentMiniMode); }) .catch((err: unknown) => { console.error(err); @@ -172,6 +183,31 @@ const SettingsModal: React.FC = ({ closeModal }) => { setChangesSaved(SaveStatus.Saved); }; + const handleShowExpandedRxChange = (e: React.ChangeEvent) => { + setChangesSaved(SaveStatus.Saving); + if (e.target.value === 'true') { + setShowExpandedRxInfo(true); + window.api.setShowExpandedRx(true); + } else { + setShowExpandedRxInfo(false); + window.api.setShowExpandedRx(false); + } + setChangesSaved(SaveStatus.Saved); + }; + + const handleTransparentMiniMode = (e: React.ChangeEvent) => { + setChangesSaved(SaveStatus.Saving); + if (e.target.value === 'true') { + window.api.setTransparentMiniMode(true); + setLocalTransparentMiniMode(true); + } else { + window.api.setTransparentMiniMode(false); + setLocalTransparentMiniMode(false); + } + setPendingRestart(true); + setChangesSaved(SaveStatus.Saved); + }; + const handleSetPtt = (pttIndex: number) => { if (pttIndex === 1) { updatePtt1KeySet(false); @@ -199,7 +235,7 @@ const SettingsModal: React.FC = ({ closeModal }) => { const radioEffects = e.target.value as RadioEffects; void window.api.SetRadioEffects(radioEffects); setRadioEffects(radioEffects); - setConfig({ ...config, radioEffects: radioEffects}); + setConfig({ ...config, radioEffects: radioEffects }); setChangesSaved(SaveStatus.Saved); }; @@ -221,75 +257,110 @@ const SettingsModal: React.FC = ({ closeModal }) => { return ( <>
-
-
+
+
Settings
-
-
+
+
VATSIM Details
- - { - // Issue #127: Strip non-digit characters instead of using type="number". - const cleanCid = e.target.value.replace(/\D/g, ''); // Remove non-digit characters - setCid(cleanCid); - debouncedCid(cleanCid); - }} - > - - debouncedPassword(e.target.value)} - > - - - - - - - - - +
+ + { + // Issue #127: Strip non-digit characters instead of using type="number". + const cleanCid = e.target.value.replace(/\D/g, ''); // Remove non-digit characters + setCid(cleanCid); + debouncedCid(cleanCid); + }} + > + + debouncedPassword(e.target.value)} + > + + + + + + +
+
+ + + + + + + + +
-
+
Audio configuration
diff --git a/src/renderer/src/components/sidebar/add-frequency.tsx b/src/renderer/src/components/sidebar/add-frequency.tsx index fecd36de..d0dbb0bb 100644 --- a/src/renderer/src/components/sidebar/add-frequency.tsx +++ b/src/renderer/src/components/sidebar/add-frequency.tsx @@ -2,7 +2,11 @@ import React, { useRef, useState } from 'react'; import useRadioState, { RadioHelper } from '../../store/radioStore'; import useSessionStore from '../../store/sessionStore'; -const AddFrequency: React.FC = () => { +export interface AddFrequencyProps { + onAddFrequency?: () => void; +} + +const AddFrequency: React.FC = ({ onAddFrequency }) => { const [readyToAdd, setReadyToAdd] = useState(false); const [previousValue, setPreviousValue] = useState(''); const [addRadio] = useRadioState((state) => [state.addRadio]); @@ -38,6 +42,9 @@ const AddFrequency: React.FC = () => { frequencyInputRef.current.value = ''; setPreviousValue(''); setReadyToAdd(false); + if (onAddFrequency) { + onAddFrequency(); + } }; const checkFrequency = (e: React.ChangeEvent) => { @@ -65,8 +72,8 @@ const AddFrequency: React.FC = () => { }; return ( -
- +
+
Add a VHF Frequency
void; +} + +const AddStation: React.FC = ({ className, style, onAddStation }) => { + const [readyToAdd, setReadyToAdd] = useState(false); + const [isConnected] = useSessionStore((state) => [state.version, state.isConnected]); + + const stationInputRef = useRef(null); + + const addStation = () => { + if (!readyToAdd || !isConnected) { + return; + } + + const callsign = stationInputRef.current?.value.toUpperCase(); + if (!callsign?.match(/^[A-Z0-9_ -]+$/) || !stationInputRef.current) { + return; + } + + void window.api.GetStation(callsign); + stationInputRef.current.value = ''; + setReadyToAdd(false); + + if (onAddStation) { + onAddStation(); + } + }; + + return ( +
+
Add a Station
+ { + e.target.value.length !== 0 ? setReadyToAdd(true) : setReadyToAdd(false); + }} + onKeyDown={(e) => { + e.key === 'Enter' && addStation(); + }} + autoFocus + > + + +
+ ); +}; + +export default AddStation; diff --git a/src/renderer/src/components/sidebar/radio-status.tsx b/src/renderer/src/components/sidebar/radio-status.tsx index 270b4999..c4871c9a 100644 --- a/src/renderer/src/components/sidebar/radio-status.tsx +++ b/src/renderer/src/components/sidebar/radio-status.tsx @@ -2,93 +2,24 @@ import React from 'react'; import useRadioState, { RadioHelper } from '../../store/radioStore'; const RadioStatus: React.FC = () => { - const [selectedRadio, removeRadio, setPendingDeletion] = useRadioState((state) => [ - state.getSelectedRadio(), - state.removeRadio, - state.setPendingDeletion - ]); + const [selectedRadio] = useRadioState((state) => [state.getSelectedRadio()]); - const awaitEndOfRxForDeletion = (frequency: number): void => { - const interval = setInterval( - (frequency: number) => { - const radio = useRadioState.getState().radios.find((r) => r.frequency === frequency); - if (!radio) { - clearInterval(interval); - return; - } - - if (!radio.currentlyRx && !radio.currentlyTx) { - void window.api.removeFrequency(radio.frequency); - removeRadio(radio.frequency); - clearInterval(interval); - } - }, - 60, - frequency - ); - - // Clear the interval after 5 seconds - setTimeout(() => { - clearInterval(interval); - }, 10000); - }; - - const handleDeleteRadio = () => { - if (!selectedRadio) { - return; - } - setPendingDeletion(selectedRadio.frequency, true); - awaitEndOfRxForDeletion(selectedRadio.frequency); - }; - - const handleForceRefresh = () => { - if (!selectedRadio) { - return; - } - if (selectedRadio.callsign === 'MANUAL') { - return; - } - void window.api.RefreshStation(selectedRadio.callsign); - }; + if (!selectedRadio) { + return; + } return ( -
-
- Radio Status +
+
+ Callsign:
- Callsign: {selectedRadio ? selectedRadio.callsign : ''} -
- - Frequency: {selectedRadio ? RadioHelper.convertHzToMhzString(selectedRadio.frequency) : ''} - -
- - Transceivers:{' '} - {selectedRadio - ? selectedRadio.callsign !== 'MANUAL' - ? selectedRadio.transceiverCount - : 'MAN' - : ''} + {selectedRadio.callsign} + Frequency: + {RadioHelper.convertHzToMhzString(selectedRadio.frequency)} + Transceivers: + + {selectedRadio.callsign !== 'MANUAL' ? selectedRadio.transceiverCount : 'MAN'} -
- -
); }; diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 16f38f05..dc89d59b 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -1,70 +1,16 @@ -import React, { useRef, useState } from 'react'; -import AddFrequency from './add-frequency'; +import React from 'react'; import RadioStatus from './radio-status'; import useSessionStore from '../../store/sessionStore'; -import useRadioState from '../../store/radioStore'; -import LastReceivedCallsigns from './lastReceivedCallsigns'; const Sidebar: React.FC = () => { - const [readyToAdd, setReadyToAdd] = useState(false); - const [version, isConnected] = useSessionStore((state) => [state.version, state.isConnected]); - const [radios] = useRadioState((state) => [state.radios]); - - const stationInputRef = useRef(null); - - const addStation = () => { - if (!readyToAdd || !isConnected) { - return; - } - - const callsign = stationInputRef.current?.value.toUpperCase(); - if (!callsign?.match(/^[A-Z0-9_ -]+$/) || !stationInputRef.current) { - return; - } - - void window.api.GetStation(callsign); - stationInputRef.current.value = ''; - setReadyToAdd(false); - }; + const [version] = useSessionStore((state) => [state.version, state.isConnected]); return ( <> -
-
- - { - e.target.value.length !== 0 ? setReadyToAdd(true) : setReadyToAdd(false); - }} - onKeyDown={(e) => { - e.key === 'Enter' && addStation(); - }} - > - -
- - - - {/* - Source: Slurper - */} - - - +
-
+
{version} |  svg { + stroke: #fff; + stroke-width: 1.1px; + } + } + + &.caption-close:active { + background-color: rgba(232, 17, 35, 0.6); + } + + > svg { + width: 12px; + height: 12px; + } + } +} +body:not(.task-overlay-is-shown).dark-theme .windows-caption-buttons .element { + stroke: #fff; +} + +.gear { + -webkit-app-region: no-drag; + cursor: pointer; + height: 100%; + display: flex; + align-items: center; + // min-width: 31px; +} diff --git a/src/renderer/src/components/titlebar/TitleBar.tsx b/src/renderer/src/components/titlebar/TitleBar.tsx new file mode 100644 index 00000000..863137ec --- /dev/null +++ b/src/renderer/src/components/titlebar/TitleBar.tsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect, useRef, ReactNode, useCallback } from 'react'; +import './TitleBar.scss'; +import TitleBarSection, { TitleBarSectionProps } from './TitleBarSection'; +import TitleBarElement, { TitleBarElementProps } from './TitleBarElement'; +import useTitleBarUtils from './useTitleBarUtils'; +import useUtilStore from '@renderer/store/utilStore'; + +interface TitleBarProps { + className?: string; + disableResize?: boolean; + children: ReactNode; +} + +const TitleBar: React.FC & { + Section: React.FC; + Element: React.FC; +} = ({ className, disableResize = false, children }: TitleBarProps) => { + const [focused, setFocused] = useState(true); + const [os, isWindowFullscreen, isWindowMaximised] = useUtilStore((state) => [ + state.platform, + state.isWindowFullscreen, + state.isWindowMaximised + ]); + const [isWindows, isLinux, isMac] = [os === 'win32', os === 'linux', os === 'darwin']; + + const titleBarRef = useRef(null); + const { + sectionRefs, + elementRefs, + calculateAvailableSpace, + calculateGap, + getSectionRef, + getElementRef + } = useTitleBarUtils(children, titleBarRef); + + useEffect(() => { + if (os.length === 0) { + window.api + .UpdatePlatform() + .then((platform: string) => { + useUtilStore.getState().updatePlatform(platform); + }) + .catch((err: unknown) => { + console.error(err); + }); + } + }, []); + + const handleResize = useCallback(() => { + const sections = React.Children.toArray(children) as React.ReactElement[]; + const spacing = 16; + + sections.sort( + (a, b) => + (b.props as TitleBarSectionProps).priority - (a.props as TitleBarSectionProps).priority + ); + + sections.forEach((section) => { + const sectionName = (section.props as TitleBarSectionProps).name; + const sectionElement = sectionRefs.current[sectionName]; + const elements = Object.values(elementRefs.current[sectionName]).filter(Boolean); + + if (elements.length === 1 && sections.length === 1) { + return; + } + + elements.sort((a, b) => { + const aPriority = Number(a?.getAttribute('data-priority') ?? Infinity); + const bPriority = Number(b?.getAttribute('data-priority') ?? Infinity); + return bPriority - aPriority; + }); + + elements.forEach((element) => { + if (!element) return; + + const gap = calculateGap(sectionName); + const isEnoughGap = gap >= spacing; + + if (gap == -1) { + return; + } + + const isElementVisible = element.style.display !== 'none'; + if (isElementVisible && !isEnoughGap) { + element.style.display = 'none'; + + if (element.getAttribute('data-priority') === '1' && sectionElement) { + sectionElement.visible = false; + } + return; + } + }); + }); + + sections.reverse().forEach((section) => { + const sectionName = (section.props as TitleBarSectionProps).name; + const sectionElement = sectionRefs.current[sectionName]; + const elements = Object.values(elementRefs.current[sectionName]).filter(Boolean); + + if (elements.length === 1 && sections.length === 1) { + return; + } + + elements.reverse().forEach((element) => { + if (!element) return; + + const isElementVisible = element.style.display !== 'none'; + const isTemporarilyHidden = + element.style.visibility === 'hidden' && element.style.display === 'block'; + if (isElementVisible && !isTemporarilyHidden) { + return; + } + + let elementWidth = element.offsetWidth; + const isEnoughSpace = calculateAvailableSpace() - elementWidth >= spacing; + const gap = calculateGap(sectionName); + + if (gap == -1) { + return; + } + let isEnoughGap = gap - elementWidth >= spacing; + + if (!isElementVisible && isEnoughSpace && isEnoughGap) { + element.style.visibility = 'hidden'; + element.style.display = 'block'; + elementWidth = element.offsetWidth; + isEnoughGap = calculateGap(sectionName) >= spacing; + } + + if (isEnoughGap && isEnoughSpace) { + if (sectionElement) { + if ( + !sectionElement.visible && + element.getAttribute('data-priority') !== '1' && + (section.props as TitleBarSectionProps).priority !== 1 + ) { + return; + } + sectionElement.visible = true; + } + + element.style.visibility = 'visible'; + } else { + element.style.display = 'none'; + } + }); + }); + }, [children, calculateAvailableSpace, calculateGap, sectionRefs, elementRefs]); + + useEffect(() => { + handleResize(); + + window.addEventListener('resize', handleResize); + window.addEventListener('focus', () => { + setFocused(true); + }); + window.addEventListener('blur', () => { + setFocused(false); + }); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('focus', () => { + setFocused(true); + }); + window.removeEventListener('blur', () => { + setFocused(false); + }); + }; + }, [children, handleResize]); + + const isTitleBarElement = ( + element: React.ReactNode + ): element is React.ReactElement => { + return ( + React.isValidElement(element) && + typeof (element.props as TitleBarElementProps).priority === 'number' + ); + }; + + const minimiseWindow = (): void => { + window.api.window.minimise(); + }; + + const maximiseWindow = (): void => { + window.api.window.maximise(); + }; + + const unmaximiseWindow = (): void => { + window.api.window.unmaximise(); + }; + + const closeWindow = (): void => { + window.api.window.close(); + }; + + return ( +
+ {React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + const { name, priority } = child.props as TitleBarSectionProps; + return ( +
+ {React.Children.map((child.props as TitleBarSectionProps).children, (element) => + isTitleBarElement(element) ? ( +
+ {element} +
+ ) : null + )} +
+ ); + } + return null; + })} + + {(isWindows || isLinux) && ( + <> +
+
+
{ + if (!disableResize) minimiseWindow(); + }} + > + + + +
+ {!isWindowMaximised && ( +
{ + if (!disableResize) maximiseWindow(); + }} + > + + + +
+ )} + {isWindowMaximised && ( +
{ + if (!disableResize) unmaximiseWindow(); + }} + > + + + + +
+ )} +
{ + closeWindow(); + }} + > + + + +
+
+
+ + )} +
+ ); +}; + +// Add a display name for easier debugging + +// Add PropTypes for additional runtime validation + +TitleBar.Section = TitleBarSection; +TitleBar.Element = TitleBarElement; + +export default TitleBar; diff --git a/src/renderer/src/components/titlebar/TitleBarElement.tsx b/src/renderer/src/components/titlebar/TitleBarElement.tsx new file mode 100644 index 00000000..f1f8f320 --- /dev/null +++ b/src/renderer/src/components/titlebar/TitleBarElement.tsx @@ -0,0 +1,23 @@ +import React, { useMemo } from 'react'; + +export interface TitleBarElementProps { + children: React.ReactNode; + priority: number; +} + +const TitleBarElement = ({ children, priority }: TitleBarElementProps): JSX.Element => { + const calculatedChildren = useMemo(() => { + return ( +
+ {children} +
+ ); + }, [children, priority]); + + return calculatedChildren; +}; + +// Add a display name for easier debugging +TitleBarElement.displayName = 'TitleBarElement'; + +export default TitleBarElement; diff --git a/src/renderer/src/components/titlebar/TitleBarSection.tsx b/src/renderer/src/components/titlebar/TitleBarSection.tsx new file mode 100644 index 00000000..022d0138 --- /dev/null +++ b/src/renderer/src/components/titlebar/TitleBarSection.tsx @@ -0,0 +1,23 @@ +import React, { useMemo } from 'react'; +import TitleBarElement from './TitleBarElement'; + +export interface TitleBarSectionProps { + children: React.ReactNode; + priority: number; + name: string; +} + +const TitleBarSection = ({ children }: TitleBarSectionProps): JSX.Element => { + const childrenWithPriorities = useMemo(() => { + return React.Children.map(children, (child) => { + if (React.isValidElement(child) && child.type === TitleBarElement) { + return child; + } + return {child}; + }); + }, [children]); + + return <>{childrenWithPriorities}; +}; + +export default TitleBarSection; diff --git a/src/renderer/src/components/titlebar/session-status/ConnectStatus.scss b/src/renderer/src/components/titlebar/session-status/ConnectStatus.scss new file mode 100644 index 00000000..3f573bbc --- /dev/null +++ b/src/renderer/src/components/titlebar/session-status/ConnectStatus.scss @@ -0,0 +1,63 @@ +@use '../../../style/variables.scss' as GlobalVars; + +.connect-status { + // background-color: darken($dark, 5%) !important; + font-family: GlobalVars.$font-medium; + // height: calc($navbar-height - 2px) !important; +} + +.freq-status { + font-family: GlobalVars.$font-medium; + padding: 1px 2px; + margin: 2px 0px; + font-size: 7px; + color: GlobalVars.$body-color; + background-color: rgb(129, 129, 129); + border-radius: 3px; + display: flex; + justify-content: center; + align-items: center; +} + +.freq-status:last-child { + margin-top: auto; +} + +.freq-status.active { + color: GlobalVars.$body-color; + background-color: rgb(249, 112, 0); +} + +.connected { + background-color: rgba(GlobalVars.$success, 0.6) !important; + color: white !important; + border-color: GlobalVars.$success !important; + cursor: pointer; +} + +.rxOnly { + background-color: rgba(GlobalVars.$info, 0.6) !important; + color: white !important; + border-color: GlobalVars.$info !important; + cursor: pointer; +} + +.txOnly { + background-color: rgba(GlobalVars.$warning, 0.6) !important; + color: white !important; + border-color: GlobalVars.$warning !important; + cursor: pointer; +} + +.disconnected { + background-color: rgba(GlobalVars.$success, 0.6) !important; + color: white !important; + border-color: GlobalVars.$success !important; + cursor: pointer; +} + +.vatsim-logo { + // padding: 2px; + height: 100%; + opacity: 0.9; +} diff --git a/src/renderer/src/components/titlebar/session-status/ConnectionStatus.tsx b/src/renderer/src/components/titlebar/session-status/ConnectionStatus.tsx new file mode 100644 index 00000000..c86bbf61 --- /dev/null +++ b/src/renderer/src/components/titlebar/session-status/ConnectionStatus.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import useSessionStore from '@renderer/store/sessionStore'; +import useErrorStore from '@renderer/store/errorStore'; +import { checkIfCallsignIsRelief, getCleanCallsign } from '../../../helpers/CallsignHelper'; +import clsx from 'clsx'; + +interface ConnectionStatusProps { + className?: string; +} + +const ConnectionStatus: React.FC = ({ className = '' }) => { + const postError = useErrorStore((state) => state.postError); + const [ + isConnected, + isConnecting, + setIsConnecting, + setIsConnected, + callsign, + isNetworkConnected, + isAtc, + setStationCallsign + ] = useSessionStore((state) => [ + state.isConnected, + state.isConnecting, + state.setIsConnecting, + state.setIsConnected, + state.callsign, + state.isNetworkConnected, + state.isAtc, + state.setStationCallsign + ]); + + const doConnect = () => { + setIsConnecting(true); + window.api + .connect() + .then((ret) => { + if (!ret) { + postError('Error connecting to AFV, check your configuration and credentials.'); + setIsConnecting(false); + setIsConnected(false); + } + }) + .catch((err: unknown) => { + console.error(err); + }); + }; + + const handleConnectDisconnect = () => { + if (isConnected) { + void window.api.disconnect(); + return; + } + + if (!isNetworkConnected) { + return; + } + + if (checkIfCallsignIsRelief(callsign) && isAtc) { + const reliefCallsign = getCleanCallsign(callsign); + window.api + .dialog( + 'question', + 'Relief callsign detected', + 'You might be using a relief callsign, please select which callsign you want to use.', + [callsign, reliefCallsign] + ) + .then((ret) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (ret.response === 0) { + setStationCallsign(callsign); + } else { + setStationCallsign(reliefCallsign); + } + }) + .then(() => { + doConnect(); + }) + .catch((err: unknown) => { + console.error(err); + }); + } else { + setStationCallsign(callsign); + doConnect(); + } + }; + + return ( + + ); + + return isConnected && callsign ? ( +
+ {callsign} +
+ ) : ( + + ); +}; + +export default ConnectionStatus; diff --git a/src/renderer/src/components/titlebar/session-status/SessionStatus.tsx b/src/renderer/src/components/titlebar/session-status/SessionStatus.tsx new file mode 100644 index 00000000..6be224b7 --- /dev/null +++ b/src/renderer/src/components/titlebar/session-status/SessionStatus.tsx @@ -0,0 +1,15 @@ +import './ConnectStatus.scss'; +import ConnectionStatus from './ConnectionStatus'; +const SessionStatus = () => { + return ( + <> +
+
+ +
+
+ + ); +}; + +export default SessionStatus; diff --git a/src/renderer/src/components/titlebar/useTitleBarUtils.ts b/src/renderer/src/components/titlebar/useTitleBarUtils.ts new file mode 100644 index 00000000..e5fc3b7e --- /dev/null +++ b/src/renderer/src/components/titlebar/useTitleBarUtils.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import React, { useRef, useCallback } from 'react'; +import { TitleBarSectionProps } from './TitleBarSection'; + +interface SectionRef { + element: HTMLDivElement | null; + visible: boolean; +} + +const useTitleBarUtils = ( + children: React.ReactNode, + titleBarRef: React.RefObject +): { + sectionRefs: React.MutableRefObject>; + elementRefs: React.MutableRefObject>>; + calculateAvailableSpace: () => number; + calculateGap: (sectionName: string) => number; + getSectionRef: (name: string) => (ref: HTMLDivElement | null) => void; + getElementRef: (sectionName: string, priority: number) => (ref: HTMLDivElement | null) => void; +} => { + const sectionRefs = useRef>({}); + const elementRefs = useRef>>({}); + + const calculateAvailableSpace = useCallback((): number => { + const titleBarWidth = titleBarRef.current?.offsetWidth ?? 0; + const sections = React.Children.toArray(children) as React.ReactElement[]; + const spacing = 15; + + return sections.reduce((sum, section) => { + const sectionName = (section.props as TitleBarSectionProps).name; + const elements = Object.values(elementRefs.current[sectionName]).filter(Boolean); + + const visibleElements = elements.filter( + (element) => element && element.style.display !== 'none' + ); + const sectionWidth = + visibleElements.reduce((sum, element) => sum + (element ? element.offsetWidth : 0), 0) + + spacing * (visibleElements.length - 1); + + return sum - sectionWidth; + }, titleBarWidth); + }, [children, titleBarRef]); + + const calculateGap = useCallback((sectionName: string): number => { + const sections = ['left', 'center', 'right']; + const currentIndex = sections.indexOf(sectionName); + + if (currentIndex === -1) { + return 0; + } + + const currentSectionElement = sectionRefs.current[sectionName]?.element; + + if (!currentSectionElement) { + return 0; + } + + const currentSectionRect = currentSectionElement.getBoundingClientRect(); + + let minGap = Infinity; + let neighborFound = false; + + if (currentIndex > 0) { + const leftNeighborSection = sections[currentIndex - 1]; + const leftNeighborElement = sectionRefs.current[leftNeighborSection]?.element; + + if (leftNeighborElement) { + neighborFound = true; + const leftNeighborRect = leftNeighborElement.getBoundingClientRect(); + const gap = currentSectionRect.left - leftNeighborRect.right; + if (gap < 0) return 0; + minGap = Math.min(minGap, gap); + } + } + + if (currentIndex < sections.length - 1) { + const rightNeighborSection = sections[currentIndex + 1]; + const rightNeighborElement = sectionRefs.current[rightNeighborSection]?.element; + + if (rightNeighborElement) { + neighborFound = true; + const rightNeighborRect = rightNeighborElement.getBoundingClientRect(); + const gap = rightNeighborRect.left - currentSectionRect.right; + if (gap < 0) return 0; + minGap = Math.min(minGap, gap); + } + } + + if (!neighborFound) { + return -1; + } + + return minGap !== Infinity ? minGap : 0; + }, []); + + const getSectionRef = useCallback( + (name: string) => (ref: HTMLDivElement | null) => { + sectionRefs.current[name] = { element: ref, visible: true }; + if (!elementRefs.current[name]) { + elementRefs.current[name] = {}; + } + }, + [] + ); + + const getElementRef = useCallback( + (sectionName: string, priority: number) => (ref: HTMLDivElement | null) => { + if (!elementRefs.current[sectionName]) { + elementRefs.current[sectionName] = {}; + } + elementRefs.current[sectionName][priority] = ref; + }, + [] + ); + + return { + sectionRefs, + elementRefs, + calculateAvailableSpace, + calculateGap, + getSectionRef, + getElementRef + }; +}; + +export default useTitleBarUtils; diff --git a/src/renderer/src/components/updater/Updater.scss b/src/renderer/src/components/updater/Updater.scss new file mode 100644 index 00000000..6b7c6e3a --- /dev/null +++ b/src/renderer/src/components/updater/Updater.scss @@ -0,0 +1,113 @@ +@use './../../style/variables.scss' as Bootstrap; + +.pkg-content { + font-family: 'RobotoMono-Bold' !important; + overflow: auto; +} + +.checking-for-update-text { + font-weight: 400; + font-size: 11px; + color: #5c5f75; + min-width: 152px; +} + +.refresh-icon { + -webkit-app-region: no-drag; + color: #a4a9b0; + font-size: 11px; +} + +.global-text { + font-size: 13px; +} + +.description-text { + font-size: 11px; +} + +.max-height-scroll { + height: 250px; +} + +.list-group { + max-height: 330px; + width: 100%; + overflow: auto; +} + +.list-group-item { + background-color: #2c2f45; + padding-bottom: 8px; + min-height: 82px; + color: Bootstrap.$body-color !important; +} + +.list-group-item:hover { + background-color: #2c2f45; + color: Bootstrap.$body-color !important; +} + +.list-group-item:focus { + background-color: #2c2f45; + color: Bootstrap.$body-color !important; +} + +.version-text { + font-size: large; +} + +.button-text { + font-size: 11px; + line-height: 10px; +} + +.packages-list { + max-height: 244px; + overflow: auto; +} + +.custom-btn:hover.btn-success { + color: white; + background-color: #2ebf4f; +} + +.custom-btn:hover.btn-danger { + color: white; + background-color: #df4958; +} + +.package-row-loading { + position: absolute; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.5); +} + +.list-group-item { + background-color: #2c2f45; + padding-bottom: 8px; + /* min-height: 82px; */ +} + +.list-group-item:hover { + background-color: #0d6efd !important; +} + +.list-group-item:focus { + background-color: #0d6efd !important; +} + +.name-text { + font-size: 17px; +} + +.structure-top { + height: calc(100vh - Bootstrap.$navbar-height); +} + +.updater-container { + display: flex; + flex-direction: column; + height: 100vh; +} diff --git a/src/renderer/src/components/updater/Updater.tsx b/src/renderer/src/components/updater/Updater.tsx new file mode 100644 index 00000000..a371e3f0 --- /dev/null +++ b/src/renderer/src/components/updater/Updater.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from 'react'; + +import './Updater.scss'; +import DownloadingUpdate from './components/DownloadingUpdate'; +import { UpdateInfo } from 'electron-updater'; +import CheckingForUpdates from './components/CheckingForUpdate'; +import UpdateError from './components/UpdateError'; +import FinishedUpdate from './components/FinishedUpdate'; +import TitleBar from '../titlebar/TitleBar'; +interface UpdaterProps { + onFinish: () => void; +} + +function Updater({ onFinish }: UpdaterProps) { + const [checkingForUpdates, setCheckingForUpdates] = useState(true); + const [updateAvailable, setUpdateAvailable] = useState(null); + const [updateDownloaded, setUpdateDownloaded] = useState(false); + const [error, setError] = useState<{ title: string; message: string } | null>(null); + + useEffect(() => { + const unsubscribeOnUpdateError = window.api.updater.onUpdateError(() => { + setUpdateAvailable(null); + setCheckingForUpdates(false); + }); + + return () => { + unsubscribeOnUpdateError(); + setCheckingForUpdates(false); + }; + }, []); + + const updateFound = (update: UpdateInfo) => { + setCheckingForUpdates(false); + setUpdateAvailable(update); + }; + + const noUpdateFound = () => { + setCheckingForUpdates(false); + onFinish(); + }; + + const onError = (title: string, message: string) => { + setError({ title, message }); + setCheckingForUpdates(false); + setUpdateAvailable(null); + }; + + const onUpdateDownloaded = () => { + setCheckingForUpdates(false); + setError(null); + + setUpdateDownloaded(true); + }; + return ( +
+ + + +
+
+
+ + +
+ Track Audio +
+
+
+ + +
+
+
+
+
+
+ {error ? ( + + ) : ( + <> + {checkingForUpdates && ( + + )} + {!checkingForUpdates && updateAvailable && ( +
+
+ {updateDownloaded ? ( + + ) : ( + + )} +
+
+ )} + + )} +
+ {/*
+ */} + {/*
+ +
+
+ {/*
+
{message}
+
*/} +
+
+ ); +} + +export default Updater; diff --git a/src/renderer/src/components/updater/components/CheckingForUpdate.tsx b/src/renderer/src/components/updater/components/CheckingForUpdate.tsx new file mode 100644 index 00000000..22eb40c4 --- /dev/null +++ b/src/renderer/src/components/updater/components/CheckingForUpdate.tsx @@ -0,0 +1,79 @@ +import Loader from './Loader'; +import { useEffect } from 'react'; +import { UpdateInfo } from 'electron-updater'; +import useUtilStore from '@renderer/store/utilStore'; + +interface CheckingForUpdatesProps { + onUpdatesFound: (update: UpdateInfo) => void; + onNoUpdatesFound: () => void; + onError: (title: string, message: string) => void; +} + +const CheckingForUpdates = ({ + onUpdatesFound, + onNoUpdatesFound, + onError +}: CheckingForUpdatesProps) => { + const [os] = useUtilStore((state) => [state.platform]); + + useEffect(() => { + if (os === 'darwin') { + checkIsTrustedAccessibility().catch((error: unknown) => { + onError('Unknown Error!', 'An unknown error occurred while checking for updates.'); + console.error('Error checking for updates: ', error); + }); + } else { + checkForUpdates(); + } + }, []); + + const checkIsTrustedAccessibility = async () => { + try { + const isTrusted = await window.api.isTrustedAccessibility(); + if (isTrusted) { + checkForUpdates(); + } else { + onError( + 'System Permissions Error!', + "Check NeoRadar's permissions and restart the client. If the issue persists restart your device." + ); + } + } catch (error) { + onError('Unknown Error!', 'An unknown error occurred while checking for updates.'); + } + }; + + const checkForUpdates = () => { + try { + window.api.updater.checkForUpdates().catch((error: unknown) => { + onError('Unknown Error!', 'An unknown error occurred while checking for updates.'); + console.error('Error checking for updates: ', error); + }); + window.api.updater + .onUpdateAvailable() + .then((update) => { + console.log('Update found: ', update); + onUpdatesFound(update); + }) + .catch(() => { + onNoUpdatesFound(); + }); + + window.api.updater + .onUpdateNotAvailable() + .then(() => { + onNoUpdatesFound(); + }) + .catch(() => { + onNoUpdatesFound(); + }); + } catch (error) { + onNoUpdatesFound(); + console.error('Error checking for updates: ', error); + } + }; + + return ; +}; + +export default CheckingForUpdates; diff --git a/src/renderer/src/components/updater/components/DownloadingUpdate.tsx b/src/renderer/src/components/updater/components/DownloadingUpdate.tsx new file mode 100644 index 00000000..873573d7 --- /dev/null +++ b/src/renderer/src/components/updater/components/DownloadingUpdate.tsx @@ -0,0 +1,81 @@ +import { ProgressInfo, UpdateInfo } from 'electron-updater'; +import { useEffect, useState } from 'react'; + +interface DownloadingUpdateProps { + updateInfo: UpdateInfo; + onUpdateDownloaded: () => void; + onError: (title: string, message: string) => void; +} + +const DownloadingUpdate = ({ updateInfo, onUpdateDownloaded, onError }: DownloadingUpdateProps) => { + const [downloadProgress, setDownloadProgress] = useState(); + + useEffect(() => { + window.api.updater + .onUpdateDownloaded() + .then(() => { + onUpdateDownloaded(); + }) + .catch(() => { + onError('Unknown Error!', 'An unknown error occurred while checking for updates.'); + }); + + const unsubscribeOnDownloadProgress = window.api.updater.onUpdateDownloadProgress( + (progress) => { + setDownloadProgress(progress); + } + ); + return () => { + unsubscribeOnDownloadProgress(); + }; + }, []); + + const bytesToMB = (bytes) => { + return (bytes / 1024 / 1024).toFixed(2); + }; + + if (!downloadProgress) { + return ( +
+
+
+
Downloading latest update... v{updateInfo.version}
+ Awaiting download to start... +
+
+
+ ); + } + + return ( +
+
+
+
+
Downloading latest update... v{updateInfo.version}
+ + + Downloaded: {bytesToMB(Math.round(downloadProgress.transferred))}/ + {bytesToMB(Math.round(downloadProgress.total))} @{' '} + {bytesToMB(Math.round(downloadProgress.bytesPerSecond))}MB/s + +
+
+
+ {downloadProgress.percent}% +
+
+
+
+
+ ); +}; + +export default DownloadingUpdate; diff --git a/src/renderer/src/components/updater/components/FinishedUpdate.tsx b/src/renderer/src/components/updater/components/FinishedUpdate.tsx new file mode 100644 index 00000000..4f0d5419 --- /dev/null +++ b/src/renderer/src/components/updater/components/FinishedUpdate.tsx @@ -0,0 +1,59 @@ +import { UpdateInfo } from 'electron-updater'; +import { useEffect, useState } from 'react'; + +interface FinishedUpdateProps { + updateInfo: UpdateInfo; +} + +const FinishedUpdate = ({ updateInfo }: FinishedUpdateProps) => { + const countdownDuration = 10; // replace with your value + const [countdown, setCountdown] = useState(countdownDuration); + + useEffect(() => { + const intervalId = setInterval(() => { + setCountdown((prevCountdown) => { + if (prevCountdown <= 1) { + clearInterval(intervalId); + window.api.updater.quitAndInstall(); + return prevCountdown; + } else { + return prevCountdown - 1; + } + }); + }, 1000); + + return () => { + clearInterval(intervalId); + }; // cleanup on component unmount + }, []); + + const percentWidth = (countdown / countdownDuration) * 100; + + return ( +
+
+
+
+
Succesfully downloaded (v{updateInfo.version})
+ + Restarting in {countdown}... +
+
+
+ {countdown} +
+
+
+
+
+ ); +}; + +export default FinishedUpdate; diff --git a/src/renderer/src/components/updater/components/Loader.scss b/src/renderer/src/components/updater/components/Loader.scss new file mode 100644 index 00000000..ed8547d9 --- /dev/null +++ b/src/renderer/src/components/updater/components/Loader.scss @@ -0,0 +1,172 @@ +$primary-color: #2c6be0; +$secondary-color: #173f88; +$accent-color: #72e5df; + +#loader { + display: flex; + justify-content: center; + align-items: center; + // height: 50vh; + margin: 25px 0px; +} + +.razar { + position: relative; + width: 70px; + height: 70px; + background-size: 100% 100%; + border-radius: 35px; + text-align: center; +} + +.progress-bar-div { + display: flex; + justify-content: center; + line-height: 1; +} + +.pulse { + position: absolute; + top: 0; + left: 0; + width: 70px; + height: 70px; + border-radius: 35px; + background: $primary-color; + animation: pulsating 2s ease-in-out; + opacity: 0; + z-index: 5; +} + +.pulse-light { + position: absolute; + top: 0; + left: 0; + width: 70px; + height: 70px; + border-radius: 35px; + background: $secondary-color; + animation: pulsating-light 2s ease-in-out; + animation-iteration-count: infinite; + opacity: 0; + z-index: 5; +} + +.ringbase { + position: absolute; + top: 0; + left: 0; + width: 70px; + height: 70px; + border-radius: 35px; + opacity: 0; + z-index: 10; +} + +.ring1 { + animation: ring 2s ease-in-out; + animation-iteration-count: infinite; +} + +.ring2 { + box-shadow: + 0 0 1px 0px $accent-color, + inset 0 0 1px 0px $accent-color; + animation: ring 2s ease-in-out; + animation-iteration-count: infinite; + animation-delay: 0.5s; +} + +@keyframes pulsating-light { + 0% { + opacity: 0.5; + } + 50% { + opacity: 0.8; + } + 100% { + opacity: 0.5; + } +} + +@keyframes pulsating { + 0% { + opacity: 0; + } + 50% { + opacity: 0.2; + } + 100% { + opacity: 0; + } +} + +@keyframes ring { + 0% { + transform: scale(0.4, 0.4); + opacity: 0; + } + 50% { + opacity: 0.6; + } + 100% { + transform: scale(1.1, 1.1); + opacity: 0; + } +} + +.pointer { + position: relative; + width: 70px; + top: 35px; + animation: circling 2s linear; + animation-iteration-count: infinite; + z-index: 20; + + div { + width: 49%; + border-bottom: 2px solid $accent-color; + } +} + +.dot { + opacity: 0; + border: 3px solid $accent-color; + border-radius: 100%; + position: absolute; + animation: blink-dot 2s ease-out; + animation-iteration-count: infinite; + z-index: 25; + + &.pos1 { + left: 10px; + top: 38px; + } + + &.pos2 { + left: 40px; + top: 18px; + animation-delay: 0.6s; + } +} + +@keyframes circling { + 0% { + transform: rotate(0deg); + } + 50% { + transform: rotate(180deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes blink-dot { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/src/renderer/src/components/updater/components/Loader.tsx b/src/renderer/src/components/updater/components/Loader.tsx new file mode 100644 index 00000000..1f18d13f --- /dev/null +++ b/src/renderer/src/components/updater/components/Loader.tsx @@ -0,0 +1,20 @@ +import './Loader.scss'; + +const Loader = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +export default Loader; diff --git a/src/renderer/src/components/updater/components/UpdateError.tsx b/src/renderer/src/components/updater/components/UpdateError.tsx new file mode 100644 index 00000000..b5d19513 --- /dev/null +++ b/src/renderer/src/components/updater/components/UpdateError.tsx @@ -0,0 +1,22 @@ +interface UpdateErrorProps { + title: string; + message: string; +} + +const UpdateError = ({ title, message }: UpdateErrorProps) => { + return ( +
+
+
+
+
{title}
+ + {message} +
+
+
+
+ ); +}; + +export default UpdateError; diff --git a/src/renderer/src/helpers/useMiniModeManager.ts b/src/renderer/src/helpers/useMiniModeManager.ts new file mode 100644 index 00000000..138dd9c4 --- /dev/null +++ b/src/renderer/src/helpers/useMiniModeManager.ts @@ -0,0 +1,96 @@ +import useRadioState from '@renderer/store/radioStore'; +import useSessionStore from '@renderer/store/sessionStore'; +import useUtilStore from '@renderer/store/utilStore'; +import { useState, useEffect } from 'react'; +import { useMediaQuery } from 'react-responsive'; + +const useMiniModeManager = () => { + const [radios] = useRadioState((state) => [state.radios]); + const [isConnected] = useSessionStore((state) => [state.isConnected]); + const [platform] = useUtilStore((state) => [state.platform]); + const [transparencyMiniMode] = useUtilStore((state) => [state.transparentMiniMode]); + + const isMiniMode = useMediaQuery({ maxWidth: '455px' }); + const isApproachingMiniMode = useMediaQuery({ maxWidth: '560px', maxHeight: '240px' }); + + const [previousWindowState, setPreviousWindowState] = useState({ + wasConnected: false, + wasInMiniMode: false, + wasApproachingMini: false + }); + + // const REGULAR_SIZE = { width: 530, height: 240 }; + const MINI_SIZE = { width: 250, height: 120 }; + + const calculateMiniModeHeight = () => { + const activeRadios = radios.filter((r) => r.rx).length; + return 22 + 24 * Math.max(activeRadios, 1); + }; + + const updateWindowSize = (width, height) => { + window.api.window.setMinimumSize(width as number, height as number); + }; + + const updateWindowAppearance = (inMiniMode) => { + if (platform === 'darwin') { + window.api.window.setWindowButtonVisibility(!inMiniMode); + } + + document.body.style.backgroundColor = + inMiniMode && transparencyMiniMode ? 'transparent' : '#2c2f45'; + }; + + useEffect(() => { + const handleWindowStateChange = () => { + // Case 1: Connection state change + // if (isConnected !== previousWindowState.wasConnected) { + // console.log('Case 1'); + // updateWindowSize( + // isConnected ? MINI_SIZE.width : REGULAR_SIZE.width, + // isConnected ? MINI_SIZE.height : REGULAR_SIZE.height + // ); + // setPreviousWindowState((prev) => ({ ...prev, wasConnected: isConnected })); + // } + + // // Case 2: User is approaching mini mode // Fix this + // if (isApproachingMiniMode && isConnected && !isMiniMode) { + // console.log('Case 2', MINI_SIZE.width, MINI_SIZE.height); + // updateWindowSize(MINI_SIZE.width, MINI_SIZE.height); + // } + + // Case 3: User has entered mini mode + if (isMiniMode && !previousWindowState.wasInMiniMode) { + const miniHeight = calculateMiniModeHeight(); + + updateWindowSize(MINI_SIZE.width, miniHeight); + updateWindowAppearance(true); + setPreviousWindowState((prev) => ({ ...prev, wasInMiniMode: true })); + } + + // Case 4: User has exited mini mode + if (!isMiniMode && previousWindowState.wasInMiniMode) { + updateWindowSize(MINI_SIZE.width, MINI_SIZE.height); + updateWindowAppearance(false); + setPreviousWindowState((prev) => ({ ...prev, wasInMiniMode: false })); + } + }; + + handleWindowStateChange(); + }, [ + isConnected, + isMiniMode, + isApproachingMiniMode, + radios, + platform, + transparencyMiniMode, + previousWindowState + ]); + + return { + isMiniMode, + isApproachingMiniMode, + isConnected + }; +}; + +export default useMiniModeManager; diff --git a/src/renderer/src/store/radioStore.ts b/src/renderer/src/store/radioStore.ts index c1bfef98..b1875a3e 100644 --- a/src/renderer/src/store/radioStore.ts +++ b/src/renderer/src/store/radioStore.ts @@ -35,6 +35,7 @@ export interface FrequencyState { interface RadioState { radios: RadioType[]; + radiosSelected: RadioType[]; pttIsOn: boolean; addRadio: (frequency: number, callsign: string, stationCallsign: string) => void; removeRadio: (frequency: number) => void; @@ -49,6 +50,8 @@ interface RadioState { setTransceiverCountForStationCallsign: (callsign: string, count: number) => void; setPendingDeletion: (frequency: number, value: boolean) => void; reset: () => void; + addOrRemoveRadioToBeDeleted: (radio: RadioType) => void; + clearRadiosToBeDeleted: () => void; } // eslint-disable-next-line @typescript-eslint/no-extraneous-class @@ -76,6 +79,7 @@ export class RadioHelper { const useRadioState = create((set) => ({ radios: [], + radiosSelected: [], pttIsOn: false, addRadio: (frequency, callsign, stationCallsign) => { if (RadioHelper.doesRadioExist(useRadioState.getState().radios, frequency)) { @@ -113,6 +117,22 @@ const useRadioState = create((set) => ({ ].sort((a, b) => radioCompare(a, b, stationCallsign)) })); }, + addOrRemoveRadioToBeDeleted: (radio) => { + if (useRadioState.getState().radiosSelected.some((r) => r.frequency === radio.frequency)) { + set((state) => ({ + radiosSelected: state.radiosSelected.filter((r) => r.frequency !== radio.frequency) + })); + } else { + set((state) => ({ + radiosSelected: [...state.radiosSelected, radio] + })); + } + }, + clearRadiosToBeDeleted: () => { + set(() => ({ + radiosSelected: [] + })); + }, removeRadio: (frequency) => { set((state) => ({ radios: state.radios.filter((radio) => radio.frequency !== frequency) @@ -154,7 +174,8 @@ const useRadioState = create((set) => ({ }, reset: () => { set(() => ({ - radios: [] + radios: [], + radiosSelected: [] })); }, setTransceiverCountForStationCallsign: (callsign, count) => { diff --git a/src/renderer/src/store/sessionStore.ts b/src/renderer/src/store/sessionStore.ts index 65fabde7..ad8634c5 100644 --- a/src/renderer/src/store/sessionStore.ts +++ b/src/renderer/src/store/sessionStore.ts @@ -10,6 +10,8 @@ interface sessionStore { frequency: number; radioGain: number; stationCallsign: string; + connectTimestamp: number | null; + setCallsign: (callsign: string) => void; setIsAtc: (isAtc: boolean) => void; setIsConnected: (isConnected: boolean) => void; @@ -34,6 +36,8 @@ const useSessionStore = create((set) => ({ pttKeyName: '', radioGain: 50, stationCallsign: '', + connectTimestamp: null, + setCallsign: (callsign) => { set({ callsign }); }, @@ -42,6 +46,7 @@ const useSessionStore = create((set) => ({ }, setIsConnected: (isConnected) => { set({ isConnected }); + set({ connectTimestamp: isConnected ? Date.now() : null }); }, setIsConnecting: (isConnecting) => { set({ isConnecting }); diff --git a/src/renderer/src/store/utilStore.ts b/src/renderer/src/store/utilStore.ts index d59cbd2c..cd3f20af 100644 --- a/src/renderer/src/store/utilStore.ts +++ b/src/renderer/src/store/utilStore.ts @@ -6,14 +6,28 @@ interface UtilStore { platform: string; ptt1KeyName: string; ptt2KeyName: string; + isWindowFullscreen: boolean; + isWindowMaximised: boolean; + showExpandedRxInfo: boolean; hasPtt1BeenSetDuringSetup: boolean; hasPtt2BeenSetDuringSetup: boolean; + isEditMode: boolean; + pendingRestart: boolean; + transparentMiniMode: boolean; + time: Date; + setIsEditMode: (isEditMode: boolean) => void; setPtt1KeyName: (ptt1KeyName: string) => void; setPtt2KeyName: (ptt2KeyName: string) => void; updateVu: (vu: number, peakVu: number) => void; updatePlatform: (platform: string) => void; updatePtt1KeySet: (hasPtt1BeenSetDuringSetup: boolean) => void; updatePtt2KeySet: (hasPtt2BeenSetDuringSetup: boolean) => void; + setWindowFullscreen: (fullscreen: boolean) => void; + setWindowMaximised: (maximised: boolean) => void; + setShowExpandedRxInfo: (showExpandedRxInfo: boolean) => void; + setTransparentMiniMode: (transparentMiniMode: boolean) => void; + setPendingRestart: (pendingRestart: boolean) => void; + setTime: (time: Date) => void; } const useUtilStore = create((set) => ({ @@ -24,6 +38,16 @@ const useUtilStore = create((set) => ({ ptt2KeyName: '', hasPtt1BeenSetDuringSetup: false, hasPtt2BeenSetDuringSetup: false, + isWindowFullscreen: false, + isWindowMaximised: false, + showExpandedRxInfo: false, + isEditMode: false, + transparentMiniMode: false, + pendingRestart: false, + time: new Date(), + setIsEditMode: (isEditMode: boolean) => { + set({ isEditMode }); + }, setPtt1KeyName: (ptt1KeyName: string) => { set({ ptt1KeyName }); }, @@ -41,6 +65,24 @@ const useUtilStore = create((set) => ({ }, updatePtt2KeySet: (hasPtt2BeenSetDuringSetup: boolean) => { set({ hasPtt2BeenSetDuringSetup }); + }, + setWindowFullscreen: (fullscreen: boolean): void => { + set({ isWindowFullscreen: fullscreen }); + }, + setWindowMaximised: (maximised: boolean): void => { + set({ isWindowMaximised: maximised }); + }, + setShowExpandedRxInfo: (showExpandedRxInfo: boolean): void => { + set({ showExpandedRxInfo }); + }, + setTransparentMiniMode: (transparentMiniMode: boolean): void => { + set({ transparentMiniMode }); + }, + setPendingRestart: (pendingRestart: boolean): void => { + set({ pendingRestart }); + }, + setTime(time: Date): void { + set({ time }); } })); diff --git a/src/renderer/src/style/GlobalRadio.scss b/src/renderer/src/style/GlobalRadio.scss new file mode 100644 index 00000000..676e5a71 --- /dev/null +++ b/src/renderer/src/style/GlobalRadio.scss @@ -0,0 +1,68 @@ +@import 'variables'; + +.unicon-overall-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.gain-bar-container { + height: 28px; + line-height: 25px; + width: fit-content; + margin: 10px; + background-color: lighten($disabled, 10%); + border-radius: $card-border-radius; + border-color: lighten($disabled, 20%); + outline: lighten($disabled, 25%) solid 2px; + padding-left: 10px; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; // Added to center items horizontally +} + +.hide-gain-container { + @media screen and (max-width: 465px) { + display: none; + } +} + +.gain-line-item { + margin-right: 10px; +} + +.gain-text { + display: inline-block; + vertical-align: middle; +} + +.sm-button { + height: 19px !important; + width: 30px !important; + min-height: 0 !important; + padding-left: 3px !important; + padding-right: 3px !important; + padding-top: 2px !important; + padding-bottom: 2px !important; + margin: 0 !important; + line-height: 2px !important; + font-size: 14px !important; + outline: darken($info, 15%) solid 1px !important; + margin-left: 5px !important; + + &.btn-success { + outline: darken($success, 15%) solid 1px !important; + } + + &.btn-warning { + outline: darken($warning, 15%) solid 1px !important; + } +} + +.gain-volume-bar { + margin-right: 10px; + width: 50px !important; +} diff --git a/src/renderer/src/style/UnicomGuard.scss b/src/renderer/src/style/UnicomGuard.scss index 7a76aa2d..f4f2e383 100644 --- a/src/renderer/src/style/UnicomGuard.scss +++ b/src/renderer/src/style/UnicomGuard.scss @@ -9,35 +9,66 @@ } .unicom-bar-container { - height: 28px; - line-height: 25px; + height: 29px; width: fit-content; - margin: 10px; + // margin: 10px; + background-color: lighten($disabled, 10%); border-radius: $card-border-radius; border-color: lighten($disabled, 20%); outline: lighten($disabled, 25%) solid 2px; padding-left: 10px; - font-size: 14px; display: flex; align-items: center; justify-content: center; // Added to center items horizontally +} + +.rx-info-expand { + height: 29px; +} +.rx-bar-container { + height: 29px; + width: fit-content; + margin: 7.5px 0px; + line-height: 29px; + background-color: lighten($disabled, 10%); + border-radius: $card-border-radius; + border-color: lighten($disabled, 20%); + outline: lighten($disabled, 25%) solid 2px; + padding: 0px 5px; + display: flex; + align-items: center; + justify-content: center; // Added to center items horizontally } .hide-unicom-container { + display: flex; + align-items: center; + @media screen and (max-width: 465px) { display: none; } } .unicom-line-item { + display: flex; + align-items: center; margin-right: 10px; } .unicom-text { display: inline-block; vertical-align: middle; + height: 100%; + align-items: center; +} + +.rx-text { + display: inline-block; + font-size: 15px; + font-weight: 600; + vertical-align: middle; } .sm-button { @@ -65,5 +96,9 @@ .unicom-volume-bar { margin-right: 10px; - width: 50px !important; + width: 80px !important; +} + +.global-volume-bar { + height: 23px; } diff --git a/src/renderer/src/style/app.scss b/src/renderer/src/style/app.scss index 6483c669..459f9927 100644 --- a/src/renderer/src/style/app.scss +++ b/src/renderer/src/style/app.scss @@ -204,7 +204,15 @@ button:disabled { } .structure { - height: calc(100vh - $navbar-height - $focusbar-height); + height: calc(100vh - $navbar-height); +} + +.sub-structure { + height: calc(100% - $focusbar-height); +} + +.sub-sub-structure { + height: calc(100vh - $focusbar-height - $navbar-height - 60px - 1rem); } .text-box-container, @@ -224,6 +232,29 @@ button:disabled { outline: lighten($disabled, 15%) solid 3px; padding: 10px; border-radius: 0.375rem; + overflow: auto; +} + +.box-container-blank { + color: $btn-text-color; + // border-color: lighten($disabled, 20%); + // outline: lighten($disabled, 15%) solid 3px; + padding: 10px; + border-radius: 0.375rem; + overflow: auto; +} + +.box-container::-webkit-scrollbar-track { + padding: 0px 0; + background-color: $dark; +} + +.box-container::-webkit-scrollbar { + width: 5px; +} + +.box-container::-webkit-scrollbar-thumb { + background-color: $body-color; } .freq-box { @@ -236,6 +267,16 @@ button:disabled { padding: 0; } +.focusbar-container { + height: $focusbar-height; + width: 100%; + position: fixed; + padding: 0 4px; + bottom: 0; + left: 0; + z-index: 1000; +} + // When changing the max-width property make sure to update the miniModeWidthBreakpoint constant in // the toggleMiniMode method in index.ts as well. @media only screen and (max-width: $mini-mode-width-break-point) { @@ -266,27 +307,129 @@ button:disabled { } } +.radio-list-expanded { + width: 100%; +} + +.radio-inactive { + background-color: $disabled !important; +} + .radio { - margin-right: 10px; - margin-top: 10px; + width: 205px; + height: 90px; + // margin: 5px; + padding: 10px; color: $btn-text-color; background-color: lighten($disabled, 5%); - border-color: lighten($disabled, 20%); - outline: lighten($disabled, 25%) solid 2px; - padding: 10px; border-radius: 0.375rem; - height: 90px; - width: 205px; + outline: lighten($disabled, 25%) solid 2px; + transition: background-color 0.2s ease; + + &:hover { + background-color: lighten($disabled, 10%); + } + + // Main layout container + .radio-content { + height: 100%; + display: flex; + gap: 5%; + } + + // Left side with frequency and controls + .radio-left { + width: 48%; + height: 100%; + display: flex; + flex-direction: column; + gap: 5%; + } + + // Right side with RX/TX + .radio-right { + width: 48%; + height: 100%; + display: flex; + flex-direction: column; + gap: 10%; + } + + // Header button + .radio-header { + width: 100%; + height: 45%; + margin-bottom: 4%; + display: flex; + justify-content: center; + align-items: center; + padding: 0; + background: transparent; + border: none; + outline: none; + cursor: pointer; + + .radio-text-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + // gap: 1px; + } + + .frequency { + font-size: 15px; + line-height: 1; + font-weight: 500; + } + + .callsign { + font-size: 14px; + line-height: 1; + font-weight: normal; + } + } + + .radio-controls { + height: 45%; + display: flex; + gap: 10%; + + .control-btn { + flex: 1; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + } + } + + .radio-button { + width: 100%; + height: 45%; + display: flex; + justify-content: center; + align-items: center; + } } -.radio:hover { - background-color: lighten($disabled, 10%); +// Grid container for multiple radios +.radio-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(205px, 1fr)); + gap: 10px; + padding: 10px; } -.radio-btn { - margin: 5px; - width: 100%; - height: 100%; +// Media queries +@media (max-width: $mini-mode-width-break-point) { + .radio-grid { + grid-template-columns: 1fr; + } + + .radio { + width: 100%; + } } select:disabled { @@ -343,8 +486,33 @@ select:disabled { cursor: pointer; } +.modal-body::-webkit-scrollbar-thumb { + background-color: $body-color; +} + +.btn-no-interact { + border: none; + outline: none; + cursor: pointer; + background: transparent; + width: 100%; + padding: 0.25rem; + + &:focus, + &:hover, + &:active { + border: none; + outline: none; + } +} + .licenses { - font-size: 10px; + font-size: 11px; + line-height: 1; +} + +.elapsed-time { + font-size: 13px; } .alert-popup { @@ -373,12 +541,12 @@ select:disabled { .mini { display: none; - height: calc(100vh - 14px) !important; - width: calc(100% - 14px); - margin-right: 7px; - margin-left: 7px; - margin-top: 7px; - margin-bottom: 7px; + // height: calc(100vh - 14px) !important; + // width: calc(100% - 14px); + // margin-right: 7px; + // margin-left: 7px; + margin-top: 1px; + // margin-bottom: 7px; float: left; font-size: $mini-font-size; } @@ -434,5 +602,29 @@ select:disabled { } ::-webkit-scrollbar-thumb:hover { - background: lighten(rgb(50, 50, 58), 10%) /* Color of the scrollbar thumb on hover */ + background: lighten(rgb(50, 50, 58), 10%); /* Color of the scrollbar thumb on hover */ +} + +.radio-text { + font-size: 20px; + font-weight: 600; +} + +.radio-sub-text { + font-size: 13px; +} + +.connection-status { + min-width: 90px; +} + +.cursor { + -webkit-app-region: no-drag; + cursor: pointer; +} + +* { + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; } diff --git a/src/renderer/src/style/variables.scss b/src/renderer/src/style/variables.scss index 3db71c45..9ff23970 100644 --- a/src/renderer/src/style/variables.scss +++ b/src/renderer/src/style/variables.scss @@ -116,8 +116,8 @@ $sub-font-size: 11px; $modal-border: 2px solid $dark-border; $modal-border-danger: 2px solid $dark-border; $sidebar-width: 250px; -$navbar-height: 46px; -$focusbar-height: 30px; +$navbar-height: 36px; +$focusbar-height: 28px; /* buttons */ $btn-padding-x: 7px; @@ -132,4 +132,4 @@ $progress-bar-transition: width 0.1s ease; /* mini mode */ $mini-font-size: 16px; -$mini-mode-width-break-point: 330px; // When changing this also update the miniModeWidthBreakPoint constant in index.ts +$mini-mode-width-break-point: 455px; // When changing this also update the miniModeWidthBreakPoint constant in index.ts diff --git a/src/shared/config.type.ts b/src/shared/config.type.ts index da1264fd..b6d8d7ab 100644 --- a/src/shared/config.type.ts +++ b/src/shared/config.type.ts @@ -19,4 +19,7 @@ export interface Configuration { // Boolean is the prior type for this property, AlwaysOnTopMode is the updated type. alwaysOnTop: boolean | AlwaysOnTopMode; radioEffects: RadioEffects; + + showExpandedRx: boolean; + transparentMiniMode: boolean; }