feat: re adding achievement notification window

This commit is contained in:
Zamitto
2025-05-14 16:37:49 -03:00
parent ae067efd5e
commit 96cfa8c015
9 changed files with 291 additions and 5 deletions

View File

@@ -73,6 +73,7 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow();
}
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en");
});

View File

@@ -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<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = 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

View File

@@ -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,

View File

@@ -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<string, BrowserWindow> = 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",

View File

@@ -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,

View File

@@ -175,7 +175,16 @@ declare global {
minimized: boolean;
}) => Promise<void>;
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
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;

View File

@@ -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(
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />
<Route
path="/achievement-notification"
element={<AchievementNotification />}
/>
</Routes>
</HashRouter>
</Provider>

View File

@@ -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;
}
}

View File

@@ -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<AchievementInfo[]>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementInfo | null>(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 (
<div
className={cn("achievement-notification", {
closing: isClosing,
})}
>
<div className="achievement-notification__content">
<img
src={currentAchievement.iconUrl}
alt={currentAchievement.displayName}
style={{ flex: 1, width: "60px" }}
/>
<div>
<p>{t("achievement_unlocked")}</p>
<p>{currentAchievement.displayName}</p>
</div>
</div>
</div>
);
}