mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 08:43:57 +00:00
feat: achievement notification custom position and animations
This commit is contained in:
BIN
src/renderer/src/assets/icons/ellipses.png
Normal file
BIN
src/renderer/src/assets/icons/ellipses.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src/renderer/src/assets/icons/trophy.png
Normal file
BIN
src/renderer/src/assets/icons/trophy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 B |
22
src/renderer/src/declaration.d.ts
vendored
22
src/renderer/src/declaration.d.ts
vendored
@@ -35,6 +35,7 @@ import type {
|
||||
CatalogueSearchResult,
|
||||
ShopAssets,
|
||||
ShopDetailsWithAssets,
|
||||
AchievementCustomNotificationPosition,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -326,14 +327,27 @@ declare global {
|
||||
cb: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
position: AchievementCustomNotificationPosition,
|
||||
achievements?: {
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isHidden: boolean;
|
||||
isRare: boolean;
|
||||
isPlatinum: boolean;
|
||||
}[]
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onCombinedAchievementsUnlocked: (
|
||||
cb: (gameCount: number, achievementCount: number) => void
|
||||
cb: (
|
||||
gameCount: number,
|
||||
achievementCount: number,
|
||||
position: AchievementCustomNotificationPosition
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onTestAchievementNotification: (cb: () => void) => Electron.IpcRenderer;
|
||||
updateAchievementCustomNotificationWindowPosition: () => Promise<void>;
|
||||
onTestAchievementNotification: (
|
||||
cb: (position: AchievementCustomNotificationPosition) => void
|
||||
) => Electron.IpcRenderer;
|
||||
updateAchievementCustomNotificationWindow: () => Promise<void>;
|
||||
|
||||
/* Themes */
|
||||
addCustomTheme: (theme: Theme) => Promise<void>;
|
||||
|
||||
@@ -1,35 +1,221 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
@keyframes achievement-in {
|
||||
$margin-horizontal: 40px;
|
||||
$margin-vertical: 52px;
|
||||
|
||||
@keyframes content-in {
|
||||
0% {
|
||||
transform: translateY(-240px);
|
||||
width: 80px;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
width: 80px;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes achievement-out {
|
||||
@keyframes content-wait {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
width: 80px;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-240px);
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trophy-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ellipses-stand-by {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes ellipses-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
scale: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes content-expand {
|
||||
0% {
|
||||
width: 80px;
|
||||
}
|
||||
100% {
|
||||
width: calc(360px - $margin-horizontal);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes title-in {
|
||||
0% {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes description-in {
|
||||
0% {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dark-overlay {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes content-out {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.achievement-notification {
|
||||
margin-top: 24px;
|
||||
margin-left: 24px;
|
||||
animation-duration: 1s;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
animation-name: achievement-in;
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: calc(360px - $margin-horizontal);
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
animation:
|
||||
content-in 450ms ease-in-out,
|
||||
content-wait 450ms ease-in-out 450ms,
|
||||
content-expand 450ms ease-in-out 900ms;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
background: url("/src/assets/icons/ellipses.png");
|
||||
background-size: contain;
|
||||
animation: ellipses-out 900ms ease-in-out;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
background: url("/src/assets/icons/trophy.png") no-repeat center;
|
||||
animation: trophy-out 900ms ease-in-out;
|
||||
}
|
||||
|
||||
&.top_left {
|
||||
margin: $margin-vertical 0 0 $margin-horizontal;
|
||||
}
|
||||
|
||||
&.top_center {
|
||||
margin: $margin-vertical 0 0 $margin-horizontal;
|
||||
}
|
||||
|
||||
&.top_right {
|
||||
margin: $margin-vertical $margin-horizontal 0 0;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
&.bottom_left {
|
||||
margin: 0 0 $margin-vertical $margin-horizontal;
|
||||
}
|
||||
|
||||
&.bottom_center {
|
||||
margin: 0 0 $margin-vertical $margin-horizontal;
|
||||
}
|
||||
|
||||
&.bottom_right {
|
||||
margin: 0 $margin-horizontal $margin-vertical 0;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
&.closing {
|
||||
animation-name: achievement-out;
|
||||
transform: translateY(-240px);
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
animation: content-out 450ms ease-in-out;
|
||||
}
|
||||
|
||||
&__container {
|
||||
width: calc(360px - $margin-horizontal);
|
||||
max-width: 100%;
|
||||
border: 1px solid #ffffff1a;
|
||||
display: flex;
|
||||
padding: 8px 16px 8px 8px;
|
||||
background: globals.$background-color;
|
||||
transform: translateY(0);
|
||||
box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.25);
|
||||
|
||||
&.top_left {
|
||||
align-self: flex-start;
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
&.top_center {
|
||||
align-self: flex-start;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
&.top_right {
|
||||
align-self: flex-start;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
&.bottom_left {
|
||||
align-self: flex-end;
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
&.bottom_center {
|
||||
align-self: flex-end;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
&.bottom_right {
|
||||
align-self: flex-end;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
@@ -37,7 +223,51 @@
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: globals.$background-color;
|
||||
padding-right: 8px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #000;
|
||||
opacity: 0;
|
||||
animation: dark-overlay 900ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
min-width: 64px;
|
||||
min-height: 64px;
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__text-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: globals.$muted-color;
|
||||
animation: title-in 450ms ease-in-out 900ms;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: globals.$body-color;
|
||||
animation: description-in 450ms ease-in-out 900ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import cn from "classnames";
|
||||
import "./achievement-notification.scss";
|
||||
import { AchievementCustomNotificationPosition } from "@types";
|
||||
|
||||
interface AchievementInfo {
|
||||
displayName: string;
|
||||
@@ -16,6 +17,8 @@ export function AchievementNotification() {
|
||||
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] =
|
||||
useState<AchievementCustomNotificationPosition>("top_left");
|
||||
|
||||
const [achievements, setAchievements] = useState<AchievementInfo[]>([]);
|
||||
const [currentAchievement, setCurrentAchievement] =
|
||||
@@ -33,9 +36,11 @@ export function AchievementNotification() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onCombinedAchievementsUnlocked(
|
||||
(gameCount, achievementCount) => {
|
||||
(gameCount, achievementCount, position) => {
|
||||
if (gameCount === 0 || achievementCount === 0) return;
|
||||
|
||||
setPosition(position);
|
||||
|
||||
setAchievements([
|
||||
{
|
||||
displayName: t("new_achievements_unlocked", {
|
||||
@@ -58,9 +63,10 @@ export function AchievementNotification() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||
(_object, _shop, achievements) => {
|
||||
(_object, _shop, position, achievements) => {
|
||||
if (!achievements?.length) return;
|
||||
|
||||
setPosition(position);
|
||||
setAchievements((ach) => ach.concat(achievements));
|
||||
|
||||
playAudio();
|
||||
@@ -73,19 +79,22 @@ export function AchievementNotification() {
|
||||
}, [playAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onTestAchievementNotification(() => {
|
||||
setAchievements((ach) =>
|
||||
ach.concat([
|
||||
{
|
||||
displayName: "Test Achievement",
|
||||
iconUrl:
|
||||
"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fc.tenor.com%2FRwKr7hVnXREAAAAC%2Fnyan-cat.gif&f=1&nofb=1&ipt=706fd8b00cbfb5b2d2621603834d5f32c0f34cce7113de228d2fcc2247a80318",
|
||||
},
|
||||
])
|
||||
);
|
||||
const unsubscribe = window.electron.onTestAchievementNotification(
|
||||
(position) => {
|
||||
setPosition(position);
|
||||
setAchievements((ach) =>
|
||||
ach.concat([
|
||||
{
|
||||
displayName: "Test Achievement",
|
||||
iconUrl:
|
||||
"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fc.tenor.com%2FRwKr7hVnXREAAAAC%2Fnyan-cat.gif&f=1&nofb=1&ipt=706fd8b00cbfb5b2d2621603834d5f32c0f34cce7113de228d2fcc2247a80318",
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
playAudio();
|
||||
});
|
||||
playAudio();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
@@ -104,7 +113,7 @@ export function AchievementNotification() {
|
||||
const zero = performance.now();
|
||||
closingAnimation.current = requestAnimationFrame(
|
||||
function animateClosing(time) {
|
||||
if (time - zero <= 1000) {
|
||||
if (time - zero <= 450) {
|
||||
closingAnimation.current = requestAnimationFrame(animateClosing);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
@@ -147,18 +156,30 @@ export function AchievementNotification() {
|
||||
return (
|
||||
<div
|
||||
className={cn("achievement-notification", {
|
||||
[position]: true,
|
||||
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
|
||||
className={cn("achievement-notification__container", {
|
||||
closing: isClosing,
|
||||
[position]: true,
|
||||
})}
|
||||
>
|
||||
<div className="achievement-notification__content">
|
||||
<img
|
||||
src={currentAchievement.iconUrl}
|
||||
alt={currentAchievement.displayName}
|
||||
className="achievement-notification__icon"
|
||||
/>
|
||||
<div className="achievement-notification__text-container">
|
||||
<p className="achievement-notification__title">
|
||||
{t("achievement_unlocked")}
|
||||
</p>
|
||||
<p className="achievement-notification__description">
|
||||
{currentAchievement.displayName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,10 +119,10 @@ export function SettingsGeneral() {
|
||||
const achievementCustomNotificationPositionOptions = useMemo(() => {
|
||||
return [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"bottom_left",
|
||||
"bottom_right",
|
||||
"top_center",
|
||||
"bottom_center",
|
||||
].map((position) => ({
|
||||
key: position,
|
||||
@@ -152,7 +152,7 @@ export function SettingsGeneral() {
|
||||
|
||||
await handleChange({ achievementCustomNotificationPosition: value });
|
||||
|
||||
window.electron.updateAchievementCustomNotificationWindowPosition();
|
||||
window.electron.updateAchievementCustomNotificationWindow();
|
||||
};
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
@@ -251,24 +251,28 @@ export function SettingsGeneral() {
|
||||
<CheckboxField
|
||||
label={t("enable_achievement_notifications")}
|
||||
checked={form.achievementNotificationsEnabled}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
onChange={async () => {
|
||||
await handleChange({
|
||||
achievementNotificationsEnabled:
|
||||
!form.achievementNotificationsEnabled,
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
window.electron.updateAchievementCustomNotificationWindow();
|
||||
}}
|
||||
/>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_achievement_custom_notifications")}
|
||||
checked={form.achievementCustomNotificationsEnabled}
|
||||
disabled={!form.achievementNotificationsEnabled}
|
||||
onChange={() =>
|
||||
handleChange({
|
||||
onChange={async () => {
|
||||
await handleChange({
|
||||
achievementCustomNotificationsEnabled:
|
||||
!form.achievementCustomNotificationsEnabled,
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
window.electron.updateAchievementCustomNotificationWindow();
|
||||
}}
|
||||
/>
|
||||
|
||||
{form.achievementNotificationsEnabled &&
|
||||
|
||||
Reference in New Issue
Block a user