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
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +565,9 @@ export class WindowManager {
|
||||
);
|
||||
|
||||
const language = userPreferences.language ?? "en";
|
||||
|
||||
this.notificationWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
userPreferences.achievementCustomNotificationPosition ?? "top-left",
|
||||
[
|
||||
const position =
|
||||
userPreferences.achievementCustomNotificationPosition ?? "top-left";
|
||||
const testAchievements = [
|
||||
generateAchievementCustomNotificationTest(t, language),
|
||||
generateAchievementCustomNotificationTest(t, language, {
|
||||
isRare: true,
|
||||
@@ -557,7 +576,17 @@ export class WindowManager {
|
||||
generateAchievementCustomNotificationTest(t, language, {
|
||||
isPlatinum: true,
|
||||
}),
|
||||
]
|
||||
];
|
||||
|
||||
if (process.platform === "linux") {
|
||||
this.sendAchievementToFocusedWindow(position, testAchievements);
|
||||
return;
|
||||
}
|
||||
|
||||
this.notificationWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
position,
|
||||
testAchievements
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
&--top-left {
|
||||
align-items: start;
|
||||
|
||||
Vendored
+6
@@ -545,6 +545,12 @@ declare global {
|
||||
achievements?: AchievementNotificationInfo[]
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onInAppAchievementUnlocked: (
|
||||
cb: (
|
||||
position: AchievementCustomNotificationPosition,
|
||||
achievements: AchievementNotificationInfo[]
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onCombinedAchievementsUnlocked: (
|
||||
cb: (
|
||||
gameCount: number,
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<HashRouter>
|
||||
<AchievementNotificationOverlay />
|
||||
<Routes>
|
||||
<Route element={<App />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
Reference in New Issue
Block a user