diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index 4c0c9b4..5e0ecbb 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -7,7 +7,13 @@ import { destroyDatabase, getDataSource } from '../db/database'; -import { createWindow, getDockIconPath } from '../window/create-window'; +import { + createWindow, + getDockIconPath, + getMainWindow, + prepareWindowForAppQuit, + showMainWindow +} from '../window/create-window'; import { setupCqrsHandlers, setupSystemHandlers, @@ -30,8 +36,13 @@ export function registerAppLifecycle(): void { await createWindow(); app.on('activate', () => { + if (getMainWindow()) { + void showMainWindow(); + return; + } + if (BrowserWindow.getAllWindows().length === 0) - createWindow(); + void createWindow(); }); }); @@ -41,6 +52,8 @@ export function registerAppLifecycle(): void { }); app.on('before-quit', async (event) => { + prepareWindowForAppQuit(); + if (getDataSource()?.isInitialized) { event.preventDefault(); shutdownDesktopUpdater(); diff --git a/electron/desktop-settings.ts b/electron/desktop-settings.ts index a27c2d4..041248b 100644 --- a/electron/desktop-settings.ts +++ b/electron/desktop-settings.ts @@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version'; export interface DesktopSettings { autoUpdateMode: AutoUpdateMode; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; @@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings { const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { autoUpdateMode: 'auto', autoStart: true, + closeToTray: true, hardwareAcceleration: true, manifestUrls: [], preferredVersion: null, @@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings { autoStart: typeof parsed.autoStart === 'boolean' ? parsed.autoStart : DEFAULT_DESKTOP_SETTINGS.autoStart, + closeToTray: typeof parsed.closeToTray === 'boolean' + ? parsed.closeToTray + : DEFAULT_DESKTOP_SETTINGS.closeToTray, vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean' ? parsed.vaapiVideoEncode : DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode, @@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial): DesktopS autoStart: typeof mergedSettings.autoStart === 'boolean' ? mergedSettings.autoStart : DEFAULT_DESKTOP_SETTINGS.autoStart, + closeToTray: typeof mergedSettings.closeToTray === 'boolean' + ? mergedSettings.closeToTray + : DEFAULT_DESKTOP_SETTINGS.closeToTray, hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' ? mergedSettings.hardwareAcceleration : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, diff --git a/electron/ipc/system.ts b/electron/ipc/system.ts index 9854ec4..8e07e39 100644 --- a/electron/ipc/system.ts +++ b/electron/ipc/system.ts @@ -34,7 +34,11 @@ import { } from '../update/desktop-updater'; import { consumePendingDeepLink } from '../app/deep-links'; import { synchronizeAutoStartSetting } from '../app/auto-start'; -import { getMainWindow, getWindowIconPath } from '../window/create-window'; +import { + getMainWindow, + getWindowIconPath, + updateCloseToTraySetting +} from '../window/create-window'; const DEFAULT_MIME_TYPE = 'application/octet-stream'; const FILE_CLIPBOARD_FORMATS = [ @@ -407,6 +411,7 @@ export function setupSystemHandlers(): void { const snapshot = updateDesktopSettings(patch); await synchronizeAutoStartSetting(snapshot.autoStart); + updateCloseToTraySetting(snapshot.closeToTray); await handleDesktopSettingsChanged(); return snapshot; }); diff --git a/electron/preload.ts b/electron/preload.ts index 07d0dc6..f956dbe 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -138,6 +138,7 @@ export interface ElectronAPI { getDesktopSettings: () => Promise<{ autoUpdateMode: 'auto' | 'off' | 'version'; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; @@ -157,6 +158,7 @@ export interface ElectronAPI { setDesktopSettings: (patch: { autoUpdateMode?: 'auto' | 'off' | 'version'; autoStart?: boolean; + closeToTray?: boolean; hardwareAcceleration?: boolean; manifestUrls?: string[]; preferredVersion?: string | null; @@ -164,6 +166,7 @@ export interface ElectronAPI { }) => Promise<{ autoUpdateMode: 'auto' | 'off' | 'version'; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; diff --git a/electron/window/create-window.ts b/electron/window/create-window.ts index 466e058..c4acabe 100644 --- a/electron/window/create-window.ts +++ b/electron/window/create-window.ts @@ -2,13 +2,19 @@ import { app, BrowserWindow, desktopCapturer, + Menu, session, - shell + shell, + Tray } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; +import { readDesktopSettings } from '../desktop-settings'; let mainWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let closeToTrayEnabled = true; +let appQuitting = false; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; @@ -40,12 +46,107 @@ export function getDockIconPath(): string | undefined { return getExistingAssetPath('macos', '1024x1024.png'); } +function getTrayIconPath(): string | undefined { + if (process.platform === 'win32') + return getExistingAssetPath('windows', 'icon.ico'); + + return getExistingAssetPath('icon.png'); +} + export { getWindowIconPath }; export function getMainWindow(): BrowserWindow | null { return mainWindow; } +function destroyTray(): void { + if (!tray) { + return; + } + + tray.destroy(); + tray = null; +} + +function requestAppQuit(): void { + prepareWindowForAppQuit(); + app.quit(); +} + +function ensureTray(): void { + if (tray) { + return; + } + + const trayIconPath = getTrayIconPath(); + + if (!trayIconPath) { + return; + } + + tray = new Tray(trayIconPath); + tray.setToolTip('MetoYou'); + tray.setContextMenu( + Menu.buildFromTemplate([ + { + label: 'Open MetoYou', + click: () => { + void showMainWindow(); + } + }, + { + type: 'separator' + }, + { + label: 'Close MetoYou', + click: () => { + requestAppQuit(); + } + } + ]) + ); + + tray.on('click', () => { + void showMainWindow(); + }); +} + +function hideWindowToTray(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + mainWindow.hide(); + emitWindowState(); +} + +export function updateCloseToTraySetting(enabled: boolean): void { + closeToTrayEnabled = enabled; +} + +export function prepareWindowForAppQuit(): void { + appQuitting = true; + destroyTray(); +} + +export async function showMainWindow(): Promise { + if (!mainWindow || mainWindow.isDestroyed()) { + await createWindow(); + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + + if (!mainWindow.isVisible()) { + mainWindow.show(); + } + + mainWindow.focus(); + emitWindowState(); +} + function emitWindowState(): void { if (!mainWindow || mainWindow.isDestroyed()) { return; @@ -60,6 +161,9 @@ function emitWindowState(): void { export async function createWindow(): Promise { const windowIconPath = getWindowIconPath(); + closeToTrayEnabled = readDesktopSettings().closeToTray; + ensureTray(); + mainWindow = new BrowserWindow({ width: 1400, height: 900, @@ -120,6 +224,15 @@ export async function createWindow(): Promise { await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html')); } + mainWindow.on('close', (event) => { + if (appQuitting || !closeToTrayEnabled) { + return; + } + + event.preventDefault(); + hideWindowToTray(); + }); + mainWindow.on('closed', () => { mainWindow = null; }); @@ -141,6 +254,14 @@ export async function createWindow(): Promise { emitWindowState(); }); + mainWindow.on('show', () => { + emitWindowState(); + }); + + mainWindow.on('hide', () => { + emitWindowState(); + }); + emitWindowState(); mainWindow.webContents.setWindowOpenHandler(({ url }) => { diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index b5179df..defb1fc 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -89,6 +89,7 @@ export interface DesktopUpdateState { export interface DesktopSettingsSnapshot { autoUpdateMode: AutoUpdateMode; autoStart: boolean; + closeToTray: boolean; hardwareAcceleration: boolean; manifestUrls: string[]; preferredVersion: string | null; @@ -99,6 +100,7 @@ export interface DesktopSettingsSnapshot { export interface DesktopSettingsPatch { autoUpdateMode?: AutoUpdateMode; autoStart?: boolean; + closeToTray?: boolean; hardwareAcceleration?: boolean; manifestUrls?: string[]; preferredVersion?: string | null; diff --git a/toju-app/src/app/core/services/notification-audio.service.ts b/toju-app/src/app/core/services/notification-audio.service.ts index 59781f4..bcb3baf 100644 --- a/toju-app/src/app/core/services/notification-audio.service.ts +++ b/toju-app/src/app/core/services/notification-audio.service.ts @@ -13,7 +13,7 @@ export enum AppSound { } /** Path prefix for audio assets (served from the `assets/audio/` folder). */ -const AUDIO_BASE = '/assets/audio'; +const AUDIO_BASE = 'assets/audio'; /** File extension used for all sound-effect assets. */ const AUDIO_EXT = 'wav'; /** localStorage key for persisting notification volume. */ @@ -36,6 +36,8 @@ export class NotificationAudioService { /** Pre-loaded audio buffers keyed by {@link AppSound}. */ private readonly cache = new Map(); + private readonly sources = new Map(); + /** Reactive notification volume (0 - 1), persisted to localStorage. */ readonly notificationVolume = signal(this.loadVolume()); @@ -46,13 +48,22 @@ export class NotificationAudioService { /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ private preload(): void { for (const sound of Object.values(AppSound)) { - const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`); + const src = this.resolveAudioUrl(sound); + const audio = new Audio(); audio.preload = 'auto'; + audio.src = src; + audio.load(); + + this.sources.set(sound, src); this.cache.set(sound, audio); } } + private resolveAudioUrl(sound: AppSound): string { + return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString(); + } + /** Read persisted volume from localStorage, falling back to the default. */ private loadVolume(): number { try { @@ -96,8 +107,9 @@ export class NotificationAudioService { */ play(sound: AppSound, volumeOverride?: number): void { const cached = this.cache.get(sound); + const src = this.sources.get(sound); - if (!cached) + if (!cached || !src) return; const vol = volumeOverride ?? this.notificationVolume(); @@ -105,12 +117,23 @@ export class NotificationAudioService { if (vol === 0) return; // skip playback when muted + if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) { + cached.load(); + } + // Clone so overlapping plays don't cut each other off. const clone = cached.cloneNode(true) as HTMLAudioElement; + clone.preload = 'auto'; clone.volume = Math.max(0, Math.min(1, vol)); clone.play().catch(() => { - /* swallow autoplay errors */ + const fallback = new Audio(src); + + fallback.preload = 'auto'; + fallback.volume = clone.volume; + fallback.play().catch(() => { + /* swallow autoplay errors */ + }); }); } } diff --git a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html index 29c2ed1..bd78089 100644 --- a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html +++ b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html @@ -8,39 +8,77 @@

Application

-
-
-
-

Launch on system startup

+
+
+
+
+

Launch on system startup

- @if (isElectron) { -

Automatically start MetoYou when you sign in

- } @else { -

This setting is only available in the desktop app.

- } + @if (isElectron) { +

Automatically start MetoYou when you sign in

+ } @else { +

This setting is only available in the desktop app.

+ } +
+ +
+
- +
+
+
+

Minimize to tray on close

+ + @if (isElectron) { +

Keep MetoYou running in the tray when you click the X button

+ } @else { +

This setting is only available in the desktop app.

+ } +
+ + +
diff --git a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts index 0e7c6ce..d9374df 100644 --- a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts +++ b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts @@ -8,6 +8,7 @@ import { CommonModule } from '@angular/common'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePower } from '@ng-icons/lucide'; +import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { PlatformService } from '../../../../core/platform'; @@ -28,7 +29,9 @@ export class GeneralSettingsComponent { readonly isElectron = this.platform.isElectron; autoStart = signal(false); + closeToTray = signal(true); savingAutoStart = signal(false); + savingCloseToTray = signal(false); constructor() { if (this.isElectron) { @@ -51,7 +54,7 @@ export class GeneralSettingsComponent { try { const snapshot = await api.setDesktopSettings({ autoStart: enabled }); - this.autoStart.set(snapshot.autoStart); + this.applyDesktopSettings(snapshot); } catch { input.checked = this.autoStart(); } finally { @@ -59,6 +62,29 @@ export class GeneralSettingsComponent { } } + async onCloseToTrayChange(event: Event): Promise { + const input = event.target as HTMLInputElement; + const enabled = !!input.checked; + const api = this.electronBridge.getApi(); + + if (!this.isElectron || !api) { + input.checked = this.closeToTray(); + return; + } + + this.savingCloseToTray.set(true); + + try { + const snapshot = await api.setDesktopSettings({ closeToTray: enabled }); + + this.applyDesktopSettings(snapshot); + } catch { + input.checked = this.closeToTray(); + } finally { + this.savingCloseToTray.set(false); + } + } + private async loadDesktopSettings(): Promise { const api = this.electronBridge.getApi(); @@ -69,7 +95,12 @@ export class GeneralSettingsComponent { try { const snapshot = await api.getDesktopSettings(); - this.autoStart.set(snapshot.autoStart); + this.applyDesktopSettings(snapshot); } catch {} } + + private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void { + this.autoStart.set(snapshot.autoStart); + this.closeToTray.set(snapshot.closeToTray); + } }