mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
Merge branch 'feature/game-achievements' into chore/test-preview
# Conflicts: # src/main/services/window-manager.ts # src/renderer/src/context/game-details/game-details.context.tsx # src/renderer/src/declaration.d.ts # src/types/index.ts # yarn.lock
This commit is contained in:
commit
ef4844b8c0
44 changed files with 3311 additions and 1446 deletions
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
Binary file not shown.
|
@ -12,6 +12,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
|||
|
||||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
|
@ -36,6 +37,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||
showRepacksModal: false,
|
||||
showGameOptionsModal: false,
|
||||
stats: null,
|
||||
achievements: [],
|
||||
hasNSFWContentBlocked: false,
|
||||
setGameColor: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
|
@ -62,6 +64,7 @@ export function GameDetailsContextProvider({
|
|||
shop,
|
||||
}: GameDetailsContextProps) {
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
|
||||
|
@ -133,6 +136,15 @@ export function GameDetailsContextProvider({
|
|||
setStats(result);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId!, shop as GameShop)
|
||||
.then((achievements) => {
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: handle user not logged in error
|
||||
});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||
|
||||
|
@ -141,6 +153,7 @@ export function GameDetailsContextProvider({
|
|||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
setAchievements([]);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
|
@ -161,6 +174,23 @@ export function GameDetailsContextProvider({
|
|||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||
(objectId, shop) => {
|
||||
if (objectId !== objectId || shop !== shop) return;
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId!, shop as GameShop)
|
||||
.then(setAchievements)
|
||||
.catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [objectId, shop]);
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
|
@ -204,6 +234,7 @@ export function GameDetailsContextProvider({
|
|||
showGameOptionsModal,
|
||||
showRepacksModal,
|
||||
stats,
|
||||
achievements,
|
||||
hasNSFWContentBlocked,
|
||||
setHasNSFWContentBlocked,
|
||||
setGameColor,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
|
@ -19,6 +20,7 @@ export interface GameDetailsContext {
|
|||
showRepacksModal: boolean;
|
||||
showGameOptionsModal: boolean;
|
||||
stats: GameStats | null;
|
||||
achievements: GameAchievement[];
|
||||
hasNSFWContentBlocked: boolean;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
|
|
12
src/renderer/src/declaration.d.ts
vendored
12
src/renderer/src/declaration.d.ts
vendored
|
@ -25,6 +25,7 @@ import type {
|
|||
UserStats,
|
||||
UserDetails,
|
||||
FriendRequestSync,
|
||||
GameAchievement,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
} from "@types";
|
||||
|
@ -68,6 +69,17 @@ declare global {
|
|||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
getGameAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<GameAchievement[]>;
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { formatDistance, subMilliseconds } from "date-fns";
|
||||
import { format, formatDistance, subMilliseconds } from "date-fns";
|
||||
import type { FormatDistanceOptions } from "date-fns";
|
||||
import {
|
||||
ptBR,
|
||||
|
@ -67,5 +67,13 @@ export function useDate() {
|
|||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
format: (timestamp: number): string => {
|
||||
const locale = getDateLocale();
|
||||
return format(
|
||||
timestamp,
|
||||
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { store } from "./store";
|
||||
|
||||
import resources from "@locales";
|
||||
import { Achievemnt } from "./pages/achievement/achievement";
|
||||
|
||||
import "./workers";
|
||||
import { RepacksContextProvider } from "./context";
|
||||
|
@ -69,6 +70,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||
<Route path="/settings" Component={Settings} />
|
||||
<Route path="/profile/:userId" Component={Profile} />
|
||||
</Route>
|
||||
<Route path="/achievement-notification" Component={Achievemnt} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
|
|
64
src/renderer/src/pages/achievement/achievement.tsx
Normal file
64
src/renderer/src/pages/achievement/achievement.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Achievemnt() {
|
||||
const { t } = useTranslation("achievement");
|
||||
|
||||
const [achievementInfo, setAchievementInfo] = useState<{
|
||||
displayName: string;
|
||||
icon: string;
|
||||
} | null>(null);
|
||||
|
||||
const audio = useMemo(() => {
|
||||
const audio = new Audio(achievementSound);
|
||||
audio.volume = 0.2;
|
||||
audio.preload = "auto";
|
||||
return audio;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||
(_object, _shop, achievements) => {
|
||||
if (!achievements) return;
|
||||
|
||||
if (achievements.length) {
|
||||
const achievement = achievements[0];
|
||||
setAchievementInfo({
|
||||
displayName: achievement.displayName,
|
||||
icon: achievement.iconUrl,
|
||||
});
|
||||
}
|
||||
|
||||
audio.play();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [audio]);
|
||||
|
||||
if (!achievementInfo) return <p>Nada</p>;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={achievementInfo.icon}
|
||||
alt={achievementInfo.displayName}
|
||||
style={{ width: 60, height: 60 }}
|
||||
/>
|
||||
<div>
|
||||
<p>{t("achievement_unlocked")}</p>
|
||||
<p>{achievementInfo.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -5,8 +5,9 @@ import { Button } from "@renderer/components";
|
|||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useDate, useFormat } from "@renderer/hooks";
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export function Sidebar() {
|
||||
const [_howLongToBeat, _setHowLongToBeat] = useState<{
|
||||
|
@ -17,9 +18,11 @@ export function Sidebar() {
|
|||
const [activeRequirement, setActiveRequirement] =
|
||||
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
|
||||
|
||||
const { gameTitle, shopDetails, stats } = useContext(gameDetailsContext);
|
||||
const { gameTitle, shopDetails, stats, achievements } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
const { format } = useDate();
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
|
@ -45,6 +48,47 @@ export function Sidebar() {
|
|||
isLoading={howLongToBeat.isLoading}
|
||||
/> */}
|
||||
|
||||
{achievements.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
padding: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
>
|
||||
{achievements.map((achievement, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
}}
|
||||
title={achievement.description}
|
||||
>
|
||||
<img
|
||||
style={{
|
||||
height: "72px",
|
||||
width: "72px",
|
||||
filter: achievement.unlocked ? "none" : "grayscale(100%)",
|
||||
}}
|
||||
src={
|
||||
achievement.unlocked ? achievement.icon : achievement.icongray
|
||||
}
|
||||
alt={achievement.displayName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p>{achievement.displayName}</p>
|
||||
{achievement.unlockTime && format(achievement.unlockTime)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
<div
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue