From 96cfa8c015d0587a149143d5a768ab1e1741773f Mon Sep 17 00:00:00 2001 From: Zamitto <167933696+zamitto@users.noreply.github.com> Date: Wed, 14 May 2025 16:37:49 -0300 Subject: [PATCH] feat: re adding achievement notification window --- src/main/index.ts | 1 + .../achievement-watcher-manager.ts | 10 ++ .../achievements/merge-achievements.ts | 9 +- src/main/services/window-manager.ts | 44 +++++- src/preload/index.ts | 27 +++- src/renderer/src/declaration.d.ts | 11 +- src/renderer/src/main.tsx | 5 + .../achievement-notification.scss | 43 ++++++ .../notification/achievement-notification.tsx | 146 ++++++++++++++++++ 9 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/pages/achievements/notification/achievement-notification.scss create mode 100644 src/renderer/src/pages/achievements/notification/achievement-notification.tsx diff --git a/src/main/index.ts b/src/main/index.ts index 3b223299..5151b956 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -73,6 +73,7 @@ app.whenReady().then(async () => { WindowManager.createMainWindow(); } + WindowManager.createNotificationWindow(); WindowManager.createSystemTray(language || "en"); }); diff --git a/src/main/services/achievements/achievement-watcher-manager.ts b/src/main/services/achievements/achievement-watcher-manager.ts index 8b076d9e..97381743 100644 --- a/src/main/services/achievements/achievement-watcher-manager.ts +++ b/src/main/services/achievements/achievement-watcher-manager.ts @@ -12,6 +12,8 @@ import { achievementsLogger } from "../logger"; import { Cracker } from "@shared"; import { publishCombinedNewAchievementNotification } from "../notifications"; import { gamesSublevel } from "@main/level"; +import { WindowManager } from "../window-manager"; +import { sleep } from "@main/helpers"; const fileStats: Map = new Map(); const fltFiles: Map> = new Map(); @@ -231,6 +233,8 @@ export class AchievementWatcherManager { }; public static async preSearchAchievements() { + await sleep(5000); + try { const newAchievementsCount = process.platform === "win32" @@ -246,6 +250,12 @@ export class AchievementWatcherManager { ); if (totalNewAchievements > 0) { + WindowManager.notificationWindow?.webContents.send( + "on-combined-achievements-unlocked", + totalNewGamesWithAchievements, + totalNewAchievements + ); + publishCombinedNewAchievementNotification( totalNewAchievements, totalNewGamesWithAchievements diff --git a/src/main/services/achievements/merge-achievements.ts b/src/main/services/achievements/merge-achievements.ts index 0385f2fc..8fff5033 100644 --- a/src/main/services/achievements/merge-achievements.ts +++ b/src/main/services/achievements/merge-achievements.ts @@ -66,7 +66,7 @@ export const mergeAchievements = async ( const newAchievements = [...newAchievementsMap.values()] .filter((achievement) => { - return !unlockedAchievements.some((localAchievement) => { + return !unlockedAchievements.slice(1).some((localAchievement) => { return ( localAchievement.name.toUpperCase() === achievement.name.toUpperCase() ); @@ -106,6 +106,13 @@ export const mergeAchievements = async ( }; }); + WindowManager.notificationWindow?.webContents.send( + "on-achievement-unlocked", + game.objectId, + game.shop, + achievementsInfo + ); + publishNewAchievementNotification({ achievements: achievementsInfo, unlockedAchievementCount: mergedLocalAchievements.length, diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 9841eb6e..ea7afa82 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -23,6 +23,7 @@ import { isStaging } from "@main/constants"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; + public static notificationWindow: Electron.BrowserWindow | null = null; private static readonly editorWindows: Map = new Map(); @@ -259,6 +260,47 @@ export class WindowManager { } } + private static loadNotificationWindowURL() { + if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { + this.notificationWindow?.loadURL( + `${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification` + ); + } else { + this.notificationWindow?.loadFile( + path.join(__dirname, "../renderer/index.html"), + { + hash: "achievement-notification", + } + ); + } + } + + public static createNotificationWindow() { + this.notificationWindow = new BrowserWindow({ + transparent: true, + maximizable: false, + autoHideMenuBar: true, + minimizable: false, + focusable: false, + skipTaskbar: true, + frame: false, + width: 350, + height: 104, + x: 0, + y: 0, + webPreferences: { + preload: path.join(__dirname, "../preload/index.mjs"), + sandbox: false, + }, + }); + this.notificationWindow.setIgnoreMouseEvents(true); + // this.notificationWindow.setVisibleOnAllWorkspaces(true, { + // visibleOnFullScreen: true, + // }); + this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); + this.loadNotificationWindowURL(); + } + public static openEditorWindow(themeId: string) { if (this.mainWindow) { const existingWindow = this.editorWindows.get(themeId); @@ -277,7 +319,7 @@ export class WindowManager { minHeight: 540, backgroundColor: "#1c1c1c", titleBarStyle: process.platform === "linux" ? "default" : "hidden", - ...(process.platform === "linux" ? { icon } : {}), + icon, trafficLightPosition: { x: 16, y: 16 }, titleBarOverlay: { symbolColor: "#DADBE1", diff --git a/src/preload/index.ts b/src/preload/index.ts index 981901d3..3edf6b0c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -207,12 +207,35 @@ contextBridge.exposeInMainWorld("electron", { return () => ipcRenderer.removeListener("on-library-batch-complete", listener); }, - onAchievementUnlocked: (cb: () => void) => { - const listener = (_event: Electron.IpcRendererEvent) => cb(); + onAchievementUnlocked: ( + cb: ( + objectId: string, + shop: GameShop, + achievements?: { displayName: string; iconUrl: string }[] + ) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + objectId: string, + shop: GameShop, + achievements?: { displayName: string; iconUrl: string }[] + ) => cb(objectId, shop, achievements); ipcRenderer.on("on-achievement-unlocked", listener); return () => ipcRenderer.removeListener("on-achievement-unlocked", listener); }, + onCombinedAchievementsUnlocked: ( + cb: (gameCount: number, achievementsCount: number) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + gameCount: number, + achievementCount: number + ) => cb(gameCount, achievementCount); + ipcRenderer.on("on-combined-achievements-unlocked", listener); + return () => + ipcRenderer.removeListener("on-combined-achievements-unlocked", listener); + }, onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0dee5767..28032d9a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -175,7 +175,16 @@ declare global { minimized: boolean; }) => Promise; extractGameDownload: (shop: GameShop, objectId: string) => Promise; - onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer; + onAchievementUnlocked: ( + cb: ( + objectId: string, + shop: GameShop, + achievements?: { displayName: string; iconUrl: string }[] + ) => void + ) => () => Electron.IpcRenderer; + onCombinedAchievementsUnlocked: ( + cb: (gameCount: number, achievementCount: number) => void + ) => () => Electron.IpcRenderer; onExtractionComplete: ( cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index bb0a3e03..c4cc1fef 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -30,6 +30,7 @@ import Settings from "./pages/settings/settings"; import Profile from "./pages/profile/profile"; import Achievements from "./pages/achievements/achievements"; import ThemeEditor from "./pages/theme-editor/theme-editor"; +import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; Sentry.init({ dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN, @@ -84,6 +85,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> + } + /> diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.scss b/src/renderer/src/pages/achievements/notification/achievement-notification.scss new file mode 100644 index 00000000..51f90432 --- /dev/null +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.scss @@ -0,0 +1,43 @@ +@use "../../../scss/globals.scss"; + +@keyframes achievement-in { + 0% { + transform: translateY(-240px); + } + 100% { + transform: translateY(0); + } +} + +@keyframes achievement-out { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(-240px); + } +} + +.achievement-notification { + margin-top: 24px; + margin-left: 24px; + animation-duration: 1s; + height: 60px; + display: flex; + animation-name: achievement-in; + transform: translateY(0); + + &.closing { + animation-name: achievement-out; + transform: translateY(-240px); + } + + &__content { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + background: globals.$background-color; + padding-right: 8px; + } +} diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx new file mode 100644 index 00000000..9aac12ad --- /dev/null +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import achievementSound from "@renderer/assets/audio/achievement.wav"; +import { useTranslation } from "react-i18next"; +import cn from "classnames"; +import "./achievement-notification.scss"; + +interface AchievementInfo { + displayName: string; + iconUrl: string; +} + +const NOTIFICATION_TIMEOUT = 4000; + +export function AchievementNotification() { + const { t } = useTranslation("achievement"); + + const [isClosing, setIsClosing] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + const [achievements, setAchievements] = useState([]); + const [currentAchievement, setCurrentAchievement] = + useState(null); + + const achievementAnimation = useRef(-1); + const closingAnimation = useRef(-1); + const visibleAnimation = useRef(-1); + + const playAudio = useCallback(() => { + const audio = new Audio(achievementSound); + audio.volume = 0.1; + audio.play(); + }, []); + + useEffect(() => { + const unsubscribe = window.electron.onCombinedAchievementsUnlocked( + (gameCount, achievementCount) => { + if (gameCount === 0 || achievementCount === 0) return; + + setAchievements([ + { + displayName: t("new_achievements_unlocked", { + gameCount, + achievementCount, + }), + iconUrl: + "https://avatars.githubusercontent.com/u/164102380?s=400&u=01a13a7b4f0c642f7e547b8e1d70440ea06fa750&v=4", + }, + ]); + + playAudio(); + } + ); + + return () => { + unsubscribe(); + }; + }, [t, playAudio]); + + useEffect(() => { + const unsubscribe = window.electron.onAchievementUnlocked( + (_object, _shop, achievements) => { + if (!achievements?.length) return; + + setAchievements((ach) => ach.concat(achievements)); + + playAudio(); + } + ); + + return () => { + unsubscribe(); + }; + }, [playAudio]); + + const hasAchievementsPending = achievements.length > 0; + + const startAnimateClosing = useCallback(() => { + cancelAnimationFrame(closingAnimation.current); + cancelAnimationFrame(visibleAnimation.current); + cancelAnimationFrame(achievementAnimation.current); + + setIsClosing(true); + + const zero = performance.now(); + closingAnimation.current = requestAnimationFrame( + function animateClosing(time) { + if (time - zero <= 1000) { + closingAnimation.current = requestAnimationFrame(animateClosing); + } else { + setIsVisible(false); + } + } + ); + }, []); + + useEffect(() => { + if (hasAchievementsPending) { + setIsClosing(false); + setIsVisible(true); + + let zero = performance.now(); + cancelAnimationFrame(closingAnimation.current); + cancelAnimationFrame(visibleAnimation.current); + cancelAnimationFrame(achievementAnimation.current); + achievementAnimation.current = requestAnimationFrame( + function animateLock(time) { + if (time - zero > NOTIFICATION_TIMEOUT) { + zero = performance.now(); + setAchievements((ach) => ach.slice(1)); + } + achievementAnimation.current = requestAnimationFrame(animateLock); + } + ); + } else { + startAnimateClosing(); + } + }, [hasAchievementsPending, startAnimateClosing]); + + useEffect(() => { + if (achievements.length) { + setCurrentAchievement(achievements[0]); + } + }, [achievements]); + + if (!isVisible || !currentAchievement) return null; + + return ( +
+
+ {currentAchievement.displayName} +
+

{t("achievement_unlocked")}

+

{currentAchievement.displayName}

+
+
+
+ ); +}