From d126e9637a8e87d0c9a4c33012a30bafcc6dcdf6 Mon Sep 17 00:00:00 2001 From: Moyase <84792959+Moyasee@users.noreply.github.com> Date: Fri, 29 May 2026 07:29:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(window-manager):=20implement=20in-app=20ac?= =?UTF-8?q?hievement=20notifications=20for=20=E2=80=A6=20(#2251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../achievements/merge-achievements.ts | 41 ++++-- src/main/services/window-manager.ts | 53 +++++-- src/preload/index.ts | 15 ++ .../achievement-notification-overlay.tsx | 129 ++++++++++++++++++ .../achievement-notification.scss | 1 + src/renderer/src/declaration.d.ts | 6 + src/renderer/src/main.tsx | 2 + 7 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/achievements/notification/achievement-notification-overlay.tsx diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 95c339121..8e39a0b3e 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -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(); + } } } diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index c0fe5a2b6..3e29f46d8 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -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,20 +565,28 @@ export class WindowManager { ); 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( "on-achievement-unlocked", - userPreferences.achievementCustomNotificationPosition ?? "top-left", - [ - generateAchievementCustomNotificationTest(t, language), - generateAchievementCustomNotificationTest(t, language, { - isRare: true, - isHidden: true, - }), - generateAchievementCustomNotificationTest(t, language, { - isPlatinum: true, - }), - ] + position, + testAchievements ); } diff --git a/src/preload/index.ts b/src/preload/index.ts index a430c85f6..88de29c52 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/src/components/achievements/notification/achievement-notification-overlay.tsx b/src/renderer/src/components/achievements/notification/achievement-notification-overlay.tsx new file mode 100644 index 000000000..c855817e0 --- /dev/null +++ b/src/renderer/src/components/achievements/notification/achievement-notification-overlay.tsx @@ -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 +> = { + "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("top-left"); + const [achievements, setAchievements] = useState< + AchievementNotificationInfo[] + >([]); + const [currentAchievement, setCurrentAchievement] = + useState(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 ( +
+ +
+ ); +} diff --git a/src/renderer/src/components/achievements/notification/achievement-notification.scss b/src/renderer/src/components/achievements/notification/achievement-notification.scss index 090c91c4f..611ca8ad7 100644 --- a/src/renderer/src/components/achievements/notification/achievement-notification.scss +++ b/src/renderer/src/components/achievements/notification/achievement-notification.scss @@ -143,6 +143,7 @@ $margin-bottom: 28px; width: 360px; height: 140px; display: flex; + position: relative; &--top-left { align-items: start; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index f3d305a2d..d73f14b27 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -545,6 +545,12 @@ declare global { achievements?: AchievementNotificationInfo[] ) => void ) => () => Electron.IpcRenderer; + onInAppAchievementUnlocked: ( + cb: ( + position: AchievementCustomNotificationPosition, + achievements: AchievementNotificationInfo[] + ) => void + ) => () => Electron.IpcRenderer; onCombinedAchievementsUnlocked: ( cb: ( gameCount: number, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 416e25978..a2fad641e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( + }> } />