feat(window-manager): implement in-app achievement notifications for … (#2251)

* feat(window-manager): implement in-app achievement notifications for Linux

- Added `sendAchievementToFocusedWindow` method to handle achievement notifications within the app on Linux/Wayland, where standalone overlays are not supported.
- Updated `mergeAchievements` to utilize the new method for in-app notifications.
- Introduced IPC listener for in-app achievement notifications in the preload script.
- Enhanced the renderer to display achievement notifications using the new overlay component.

This update improves user experience by providing timely feedback on achievements directly within the application interface.

* chore: remove comments

* fix: points positioning in a custom achievement window

---------

Co-authored-by: Chubby Granny Chaser <weak.fern2638@heliokroger.com>
This commit is contained in:
Moyase
2026-05-29 07:29:35 +03:00
committed by GitHub
parent aa2bd3809c
commit d126e9637a
7 changed files with 225 additions and 22 deletions
@@ -142,18 +142,14 @@ export const mergeAchievements = async (
game.title
);
const shouldUseCustomNotification =
const customEnabled =
userPreferences.achievementCustomNotificationsEnabled !== false &&
process.platform !== "darwin" &&
!!WindowManager.notificationWindow;
process.platform !== "darwin";
if (shouldUseCustomNotification) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementsInfo
);
} else {
const position =
userPreferences.achievementCustomNotificationPosition ?? "top-left";
const publishOsNotification = () =>
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
@@ -161,6 +157,31 @@ export const mergeAchievements = async (
gameTitle: game.title,
gameIcon: game.iconUrl,
});
if (process.platform === "linux") {
const shownInApp =
customEnabled &&
WindowManager.sendAchievementToFocusedWindow(
position,
achievementsInfo
);
if (!shownInApp) {
publishOsNotification();
}
} else {
const shouldUseCustomNotification =
customEnabled && !!WindowManager.notificationWindow;
if (shouldUseCustomNotification) {
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
position,
achievementsInfo
);
} else {
publishOsNotification();
}
}
}
+36 -7
View File
@@ -6,6 +6,7 @@ import trayIcon from "@resources/tray-icon.png?asset";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import type {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
ScreenState,
UserPreferences,
} from "@types";
@@ -482,10 +483,30 @@ export class WindowManager {
};
}
public static sendAchievementToFocusedWindow(
position: AchievementCustomNotificationPosition,
achievements: AchievementNotificationInfo[]
): boolean {
const candidates = [this.bigPicture, this.mainWindow];
for (const window of candidates) {
if (window && !window.isDestroyed() && window.isFocused()) {
window.webContents.send(
"on-achievement-unlocked-in-app",
position,
achievements
);
return true;
}
}
return false;
}
public static async createNotificationWindow() {
if (this.notificationWindow) return;
if (process.platform === "darwin") {
if (process.platform === "darwin" || process.platform === "linux") {
return;
}
@@ -544,11 +565,9 @@ export class WindowManager {
);
const language = userPreferences.language ?? "en";
this.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
[
const position =
userPreferences.achievementCustomNotificationPosition ?? "top-left";
const testAchievements = [
generateAchievementCustomNotificationTest(t, language),
generateAchievementCustomNotificationTest(t, language, {
isRare: true,
@@ -557,7 +576,17 @@ export class WindowManager {
generateAchievementCustomNotificationTest(t, language, {
isPlatinum: true,
}),
]
];
if (process.platform === "linux") {
this.sendAchievementToFocusedWindow(position, testAchievements);
return;
}
this.notificationWindow?.webContents.send(
"on-achievement-unlocked",
position,
testAchievements
);
}
+15
View File
@@ -760,6 +760,21 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
onInAppAchievementUnlocked: (
cb: (
position: AchievementCustomNotificationPosition,
achievements: AchievementNotificationInfo[]
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
position: AchievementCustomNotificationPosition,
achievements: AchievementNotificationInfo[]
) => cb(position, achievements);
ipcRenderer.on("on-achievement-unlocked-in-app", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked-in-app", listener);
},
onCombinedAchievementsUnlocked: (
cb: (
gameCount: number,
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import {
getAchievementSoundUrl,
getAchievementSoundVolume,
} from "@renderer/helpers";
import { AchievementNotificationItem } from "./achievement-notification";
const NOTIFICATION_TIMEOUT = 4000;
const anchorByPosition: Record<
AchievementCustomNotificationPosition,
Pick<CSSProperties, "justifyContent" | "alignItems">
> = {
"top-left": { justifyContent: "flex-start", alignItems: "flex-start" },
"top-center": { justifyContent: "center", alignItems: "flex-start" },
"top-right": { justifyContent: "flex-end", alignItems: "flex-start" },
"bottom-left": { justifyContent: "flex-start", alignItems: "flex-end" },
"bottom-center": { justifyContent: "center", alignItems: "flex-end" },
"bottom-right": { justifyContent: "flex-end", alignItems: "flex-end" },
};
export function AchievementNotificationOverlay() {
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] =
useState<AchievementCustomNotificationPosition>("top-left");
const [achievements, setAchievements] = useState<
AchievementNotificationInfo[]
>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementNotificationInfo | null>(null);
const achievementAnimation = useRef(-1);
const closingAnimation = useRef(-1);
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onInAppAchievementUnlocked(
(nextPosition, nextAchievements) => {
if (!nextAchievements?.length) return;
if (nextPosition) setPosition(nextPosition);
setAchievements((current) => current.concat(nextAchievements));
playAudio();
}
);
return () => unsubscribe();
}, [playAudio]);
const hasAchievementsPending = achievements.length > 0;
const startAnimateClosing = useCallback(() => {
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
setIsClosing(true);
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 450) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
setAchievements((current) => current.slice(1));
}
}
);
}, []);
useEffect(() => {
if (!hasAchievementsPending) return;
setIsClosing(false);
setIsVisible(true);
let zero = performance.now();
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
achievementAnimation.current = requestAnimationFrame(
function animateLock(time) {
if (time - zero > NOTIFICATION_TIMEOUT) {
zero = performance.now();
startAnimateClosing();
}
achievementAnimation.current = requestAnimationFrame(animateLock);
}
);
}, [hasAchievementsPending, startAnimateClosing, currentAchievement]);
useEffect(() => {
if (achievements.length) {
setCurrentAchievement(achievements[0]);
}
}, [achievements]);
if (!isVisible || !currentAchievement) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
pointerEvents: "none",
zIndex: 999999,
...anchorByPosition[position],
}}
>
<AchievementNotificationItem
achievement={currentAchievement}
isClosing={isClosing}
position={position}
/>
</div>
);
}
@@ -143,6 +143,7 @@ $margin-bottom: 28px;
width: 360px;
height: 140px;
display: flex;
position: relative;
&--top-left {
align-items: start;
+6
View File
@@ -545,6 +545,12 @@ declare global {
achievements?: AchievementNotificationInfo[]
) => void
) => () => Electron.IpcRenderer;
onInAppAchievementUnlocked: (
cb: (
position: AchievementCustomNotificationPosition,
achievements: AchievementNotificationInfo[]
) => void
) => () => Electron.IpcRenderer;
onCombinedAchievementsUnlocked: (
cb: (
gameCount: number,
+2
View File
@@ -34,6 +34,7 @@ import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
import { AchievementNotificationOverlay } from "./components/achievements/notification/achievement-notification-overlay";
import GameLauncher from "./pages/game-launcher/game-launcher";
import BigPictureApp from "../../big-picture/src/app";
import BigPictureCatalogue from "../../big-picture/src/pages/catalogue/catalogue";
@@ -97,6 +98,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<AchievementNotificationOverlay />
<Routes>
<Route element={<App />}>
<Route path="/" element={<Home />} />