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 game.title
); );
const shouldUseCustomNotification = const customEnabled =
userPreferences.achievementCustomNotificationsEnabled !== false && userPreferences.achievementCustomNotificationsEnabled !== false &&
process.platform !== "darwin" && process.platform !== "darwin";
!!WindowManager.notificationWindow;
if (shouldUseCustomNotification) { const position =
WindowManager.notificationWindow?.webContents.send( userPreferences.achievementCustomNotificationPosition ?? "top-left";
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left", const publishOsNotification = () =>
achievementsInfo
);
} else {
publishNewAchievementNotification({ publishNewAchievementNotification({
achievements: achievementsInfo, achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length, unlockedAchievementCount: mergedLocalAchievements.length,
@@ -161,6 +157,31 @@ export const mergeAchievements = async (
gameTitle: game.title, gameTitle: game.title,
gameIcon: game.iconUrl, 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();
}
} }
} }
+41 -12
View File
@@ -6,6 +6,7 @@ import trayIcon from "@resources/tray-icon.png?asset";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared"; import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import type { import type {
AchievementCustomNotificationPosition, AchievementCustomNotificationPosition,
AchievementNotificationInfo,
ScreenState, ScreenState,
UserPreferences, UserPreferences,
} from "@types"; } 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() { public static async createNotificationWindow() {
if (this.notificationWindow) return; if (this.notificationWindow) return;
if (process.platform === "darwin") { if (process.platform === "darwin" || process.platform === "linux") {
return; return;
} }
@@ -544,20 +565,28 @@ export class WindowManager {
); );
const language = userPreferences.language ?? "en"; const language = userPreferences.language ?? "en";
const position =
userPreferences.achievementCustomNotificationPosition ?? "top-left";
const testAchievements = [
generateAchievementCustomNotificationTest(t, language),
generateAchievementCustomNotificationTest(t, language, {
isRare: true,
isHidden: true,
}),
generateAchievementCustomNotificationTest(t, language, {
isPlatinum: true,
}),
];
if (process.platform === "linux") {
this.sendAchievementToFocusedWindow(position, testAchievements);
return;
}
this.notificationWindow?.webContents.send( this.notificationWindow?.webContents.send(
"on-achievement-unlocked", "on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left", position,
[ testAchievements
generateAchievementCustomNotificationTest(t, language),
generateAchievementCustomNotificationTest(t, language, {
isRare: true,
isHidden: true,
}),
generateAchievementCustomNotificationTest(t, language, {
isPlatinum: true,
}),
]
); );
} }
+15
View File
@@ -760,6 +760,21 @@ contextBridge.exposeInMainWorld("electron", {
return () => return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener); 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: ( onCombinedAchievementsUnlocked: (
cb: ( cb: (
gameCount: number, 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; width: 360px;
height: 140px; height: 140px;
display: flex; display: flex;
position: relative;
&--top-left { &--top-left {
align-items: start; align-items: start;
+6
View File
@@ -545,6 +545,12 @@ declare global {
achievements?: AchievementNotificationInfo[] achievements?: AchievementNotificationInfo[]
) => void ) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onInAppAchievementUnlocked: (
cb: (
position: AchievementCustomNotificationPosition,
achievements: AchievementNotificationInfo[]
) => void
) => () => Electron.IpcRenderer;
onCombinedAchievementsUnlocked: ( onCombinedAchievementsUnlocked: (
cb: ( cb: (
gameCount: number, gameCount: number,
+2
View File
@@ -34,6 +34,7 @@ import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library"; import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications"; import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; 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 GameLauncher from "./pages/game-launcher/game-launcher";
import BigPictureApp from "../../big-picture/src/app"; import BigPictureApp from "../../big-picture/src/app";
import BigPictureCatalogue from "../../big-picture/src/pages/catalogue/catalogue"; import BigPictureCatalogue from "../../big-picture/src/pages/catalogue/catalogue";
@@ -97,6 +98,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}> <Provider store={store}>
<HashRouter> <HashRouter>
<AchievementNotificationOverlay />
<Routes> <Routes>
<Route element={<App />}> <Route element={<App />}>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />