feat: Close to tray
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<AppSound, HTMLAudioElement>();
|
||||
|
||||
private readonly sources = new Map<AppSound, string>();
|
||||
|
||||
/** 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 */
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,39 +8,77 @@
|
||||
<h4 class="text-sm font-semibold text-foreground">Application</h4>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||
[class.opacity-60]="!isElectron"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||
[class.opacity-60]="!isElectron"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
|
||||
|
||||
@if (isElectron) {
|
||||
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
||||
} @else {
|
||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
||||
}
|
||||
@if (isElectron) {
|
||||
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
|
||||
} @else {
|
||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
||||
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="autoStart()"
|
||||
[disabled]="!isElectron || savingAutoStart()"
|
||||
(change)="onAutoStartChange($event)"
|
||||
id="general-auto-start-toggle"
|
||||
aria-label="Toggle launch on startup"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
[class.cursor-pointer]="isElectron && !savingAutoStart()"
|
||||
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="autoStart()"
|
||||
[disabled]="!isElectron || savingAutoStart()"
|
||||
(change)="onAutoStartChange($event)"
|
||||
id="general-auto-start-toggle"
|
||||
aria-label="Toggle launch on startup"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
<div
|
||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||
[class.opacity-60]="!isElectron"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Minimize to tray on close</p>
|
||||
|
||||
@if (isElectron) {
|
||||
<p class="text-xs text-muted-foreground">Keep MetoYou running in the tray when you click the X button</p>
|
||||
} @else {
|
||||
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="relative inline-flex items-center"
|
||||
[class.cursor-pointer]="isElectron && !savingCloseToTray()"
|
||||
[class.cursor-not-allowed]="!isElectron || savingCloseToTray()"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="closeToTray()"
|
||||
[disabled]="!isElectron || savingCloseToTray()"
|
||||
(change)="onCloseToTrayChange($event)"
|
||||
id="general-close-to-tray-toggle"
|
||||
aria-label="Toggle minimize to tray on close"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user