mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: adding change hero
This commit is contained in:
commit
58502aeb1f
47 changed files with 3572 additions and 1461 deletions
|
|
@ -9,7 +9,7 @@
|
|||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
|
||||
/>
|
||||
</head>
|
||||
<body style="background-color: #1c1c1c">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ globalStyle("body", {
|
|||
userSelect: "none",
|
||||
fontFamily: "Noto Sans, sans-serif",
|
||||
fontSize: vars.size.body,
|
||||
background: vars.color.background,
|
||||
color: vars.color.body,
|
||||
margin: "0",
|
||||
});
|
||||
|
|
|
|||
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";
|
||||
|
|
@ -65,6 +66,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 { Achievement } 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={Achievement} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
|
|
|
|||
44
src/renderer/src/pages/achievement/achievement.css.ts
Normal file
44
src/renderer/src/pages/achievement/achievement.css.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { vars } from "../../theme.css";
|
||||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
const animationIn = keyframes({
|
||||
"0%": { transform: `translateY(-240px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
const animationOut = keyframes({
|
||||
"0%": { transform: `translateY(0)` },
|
||||
"100%": { transform: "translateY(-240px)" },
|
||||
});
|
||||
|
||||
export const container = recipe({
|
||||
base: {
|
||||
marginTop: "24px",
|
||||
marginLeft: "24px",
|
||||
animationDuration: "1.0s",
|
||||
height: "60px",
|
||||
display: "flex",
|
||||
},
|
||||
variants: {
|
||||
closing: {
|
||||
true: {
|
||||
animationName: animationOut,
|
||||
transform: "translateY(-240px)",
|
||||
},
|
||||
false: {
|
||||
animationName: animationIn,
|
||||
transform: "translateY(0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
background: vars.color.background,
|
||||
paddingRight: "8px",
|
||||
});
|
||||
117
src/renderer/src/pages/achievement/achievement.tsx
Normal file
117
src/renderer/src/pages/achievement/achievement.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievement.css";
|
||||
|
||||
interface AchievementInfo {
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
const NOTIFICATION_TIMEOUT = 4000;
|
||||
|
||||
export function Achievement() {
|
||||
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 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 || !achievements.length) return;
|
||||
|
||||
setAchievements((ach) => ach.concat(achievements));
|
||||
|
||||
audio.play();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [audio]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (achievements.length) {
|
||||
setCurrentAchievement(achievements[0]);
|
||||
}
|
||||
}, [achievements]);
|
||||
|
||||
if (!isVisible || !currentAchievement) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container({ closing: isClosing })}>
|
||||
<div className={styles.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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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