mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-06-02 06:14:48 +02:00
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:
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+129
@@ -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;
|
||||||
|
|||||||
Vendored
+6
@@ -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,
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user