fix: Game detection improvements
Some checks failed
Queue Release Build / prepare (push) Successful in 27s
Deploy Web Apps / deploy (push) Successful in 10m8s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled

This commit is contained in:
2026-05-17 17:47:40 +02:00
parent 8631290c01
commit a173299ad3
14 changed files with 1942 additions and 34 deletions

View File

@@ -215,6 +215,24 @@ export interface ContextMenuParams {
};
}
export interface ActiveGameCandidate {
processName: string;
rawProcessName: string;
executablePath?: string;
windowTitle?: string;
pid?: number;
isFullscreen: boolean;
bounds?: { width: number; height: number };
confidence: number;
source: 'foreground' | 'process-scan';
reasons: string[];
}
export interface ActiveGameCandidateResult {
candidate: ActiveGameCandidate | null;
fallbackProcessNames: string[];
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
@@ -223,6 +241,9 @@ export interface ElectronApi {
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
getActiveGameCandidate?: () => Promise<ActiveGameCandidateResult>;
getIgnoredGameProcesses?: () => Promise<string[]>;
setIgnoredGameProcesses?: (list: string[]) => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;

View File

@@ -15,7 +15,7 @@ infrastructure adapters and UI.
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` |
| **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |

View File

@@ -120,7 +120,7 @@ export class GameActivityService implements OnDestroy {
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
return;
}
@@ -154,14 +154,33 @@ export class GameActivityService implements OnDestroy {
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
if (!api?.getRunningProcessNames && !api?.getActiveGameCandidate) {
return;
}
this.scanInFlight = true;
try {
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
const candidateResult = api.getActiveGameCandidate
? await api.getActiveGameCandidate().catch(() => null)
: null;
let processNames: string[];
let preferredProcessName: string | undefined;
if (candidateResult?.candidate) {
// Main process already scored & filtered this; trust it.
preferredProcessName = candidateResult.candidate.rawProcessName ?? candidateResult.candidate.processName;
processNames = [preferredProcessName];
} else if (candidateResult && candidateResult.fallbackProcessNames.length > 0) {
processNames = candidateResult.fallbackProcessNames.slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
} else if (!candidateResult && api.getRunningProcessNames) {
// Old preload without the new API: fall back to legacy whole-system scan.
processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
} else {
processNames = [];
}
const processHash = this.buildProcessHash(processNames);
if (processHash === this.lastProcessHash) {
@@ -170,6 +189,12 @@ export class GameActivityService implements OnDestroy {
this.lastProcessHash = processHash;
if (processNames.length === 0) {
this.ngZone.run(() => this.applyMatchedGame(null));
return;
}
const matchedGame = await this.matchRunningGame(processNames);
this.ngZone.run(() => this.applyMatchedGame(matchedGame));

View File

@@ -139,4 +139,61 @@
</div>
</div>
</section>
@if (isElectron) {
<section>
<div class="flex items-center gap-2 mb-3">
<h4 class="text-sm font-semibold text-foreground">Game detection</h4>
</div>
<div class="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<p class="text-xs text-muted-foreground">
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide
apps that get mistakenly identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively
against the executable name without its extension.
</p>
<div class="flex items-center gap-2">
<input
type="text"
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Process name (e.g. spotify)"
[value]="ignoredProcessDraft()"
(input)="onIgnoredProcessDraftChange($event)"
(keydown.enter)="addIgnoredProcess()"
aria-label="Process name to ignore"
/>
<button
type="button"
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
[disabled]="savingIgnoredGameProcesses() || !ignoredProcessDraft().trim()"
(click)="addIgnoredProcess()"
>
Add
</button>
</div>
@if (ignoredGameProcesses().length === 0) {
<p class="text-xs text-muted-foreground italic">No ignored processes yet.</p>
} @else {
<ul class="flex flex-wrap gap-2">
@for (entry of ignoredGameProcesses(); track entry) {
<li class="inline-flex items-center gap-1 rounded-md bg-secondary/40 px-2 py-1 text-xs text-foreground">
<span>{{ entry }}</span>
<button
type="button"
class="text-muted-foreground hover:text-foreground"
[disabled]="savingIgnoredGameProcesses()"
(click)="removeIgnoredProcess(entry)"
[attr.aria-label]="'Remove ' + entry + ' from ignore list'"
>
×
</button>
</li>
}
</ul>
}
</div>
</section>
}
</div>

View File

@@ -36,12 +36,16 @@ export class GeneralSettingsComponent {
closeToTray = signal(true);
savingAutoStart = signal(false);
savingCloseToTray = signal(false);
ignoredGameProcesses = signal<string[]>([]);
ignoredProcessDraft = signal('');
savingIgnoredGameProcesses = signal(false);
constructor() {
this.loadGeneralSettings();
if (this.isElectron) {
void this.loadDesktopSettings();
void this.loadIgnoredGameProcesses();
}
}
@@ -131,4 +135,61 @@ export class GeneralSettingsComponent {
this.autoStart.set(snapshot.autoStart);
this.closeToTray.set(snapshot.closeToTray);
}
onIgnoredProcessDraftChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.ignoredProcessDraft.set(input.value);
}
async addIgnoredProcess(): Promise<void> {
const draft = this.ignoredProcessDraft().trim();
if (!draft) {
return;
}
const next = Array.from(new Set([...this.ignoredGameProcesses(), draft]));
await this.saveIgnoredGameProcesses(next);
this.ignoredProcessDraft.set('');
}
async removeIgnoredProcess(name: string): Promise<void> {
const next = this.ignoredGameProcesses().filter((entry) => entry !== name);
await this.saveIgnoredGameProcesses(next);
}
private async loadIgnoredGameProcesses(): Promise<void> {
const api = this.electronBridge.getApi();
if (!api?.getIgnoredGameProcesses) {
return;
}
try {
const list = await api.getIgnoredGameProcesses();
this.ignoredGameProcesses.set(list);
} catch {}
}
private async saveIgnoredGameProcesses(list: string[]): Promise<void> {
const api = this.electronBridge.getApi();
if (!api?.setIgnoredGameProcesses) {
return;
}
this.savingIgnoredGameProcesses.set(true);
try {
const normalized = await api.setIgnoredGameProcesses(list);
this.ignoredGameProcesses.set(normalized);
} finally {
this.savingIgnoredGameProcesses.set(false);
}
}
}