feat: adding aria2

This commit is contained in:
Chubby Granny Chaser 2024-05-20 02:21:11 +01:00
parent a89e6760da
commit 4941709296
No known key found for this signature in database
58 changed files with 895 additions and 1329 deletions

View file

@ -19,7 +19,6 @@ import {
setUserPreferences,
toggleDraggingDisabled,
} from "@renderer/features";
import { GameStatusHelper } from "@shared";
document.body.classList.add(themeClass);
@ -54,7 +53,7 @@ export function App({ children }: AppProps) {
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (GameStatusHelper.isReady(downloadProgress.game.status)) {
if (downloadProgress.game.progress === 1) {
clearDownload();
updateLibrary();
return;

View file

@ -43,5 +43,11 @@ export const backdrop = recipe({
backgroundColor: "rgba(0, 0, 0, 0)",
},
},
windows: {
true: {
// SPACING_UNIT * 3 + title bar spacing
paddingTop: `${SPACING_UNIT * 3 + 35}px`,
},
},
},
});

View file

@ -7,6 +7,13 @@ export interface BackdropProps {
export function Backdrop({ isClosing = false, children }: BackdropProps) {
return (
<div className={styles.backdrop({ closing: isClosing })}>{children}</div>
<div
className={styles.backdrop({
closing: isClosing,
windows: window.electron.platform === "win32",
})}
>
{children}
</div>
);
}

View file

@ -7,17 +7,16 @@ import { vars } from "../../theme.css";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
import { GameStatus, GameStatusHelper } from "@shared";
export function BottomPanel() {
const { t } = useTranslation("bottom_panel");
const navigate = useNavigate();
const { game, progress, downloadSpeed, eta } = useDownload();
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading =
game && GameStatusHelper.isDownloading(game.status ?? null);
lastPacket?.game && lastPacket?.game.status === "active";
const [version, setVersion] = useState("");
@ -27,17 +26,8 @@ export function BottomPanel() {
const status = useMemo(() => {
if (isGameDownloading) {
if (game.status === GameStatus.DownloadingMetadata)
return t("downloading_metadata", { title: game.title });
if (game.status === GameStatus.CheckingFiles)
return t("checking_files", {
title: game.title,
percentage: progress,
});
return t("downloading", {
title: game?.title,
title: lastPacket?.game.title,
percentage: progress,
eta,
speed: downloadSpeed,
@ -45,7 +35,7 @@ export function BottomPanel() {
}
return t("no_downloads_in_progress");
}, [t, isGameDownloading, game, progress, eta, downloadSpeed]);
}, [t, isGameDownloading, lastPacket?.game, progress, eta, downloadSpeed]);
return (
<footer

View file

@ -10,7 +10,6 @@ import { useDownload, useLibrary } from "@renderer/hooks";
import { routes } from "./routes";
import * as styles from "./sidebar.css";
import { GameStatus, GameStatusHelper } from "@shared";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@ -35,14 +34,14 @@ export function Sidebar() {
const location = useLocation();
const { game: gameDownloading, progress } = useDownload();
const { lastPacket, progress } = useDownload();
useEffect(() => {
updateLibrary();
}, [gameDownloading?.id, updateLibrary]);
}, [lastPacket?.game.id, updateLibrary]);
const isDownloading = library.some((game) =>
GameStatusHelper.isDownloading(game.status)
const isDownloading = library.some(
(game) => game.status === "active" && game.progress !== 1
);
const sidebarRef = useRef<HTMLElement>(null);
@ -101,18 +100,9 @@ export function Sidebar() {
}, [isResizing]);
const getGameTitle = (game: Game) => {
if (game.status === GameStatus.Paused)
return t("paused", { title: game.title });
if (gameDownloading?.id === game.id) {
const isVerifying = GameStatusHelper.isVerifying(gameDownloading.status);
if (isVerifying)
return t(gameDownloading.status!, {
title: game.title,
percentage: progress,
});
if (game.status === "paused") return t("paused", { title: game.title });
if (lastPacket?.game.id === game.id) {
return t("downloading", {
title: game.title,
percentage: progress,
@ -183,7 +173,7 @@ export function Sidebar() {
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === GameStatus.Cancelled,
muted: game.status === "removed",
})}
>
<button

View file

@ -7,7 +7,7 @@ import type {
HowLongToBeatCategory,
ShopDetails,
Steam250Game,
TorrentProgress,
DownloadProgress,
UserPreferences,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -31,7 +31,7 @@ declare global {
pauseGameDownload: (gameId: number) => Promise<void>;
resumeGameDownload: (gameId: number) => Promise<void>;
onDownloadProgress: (
cb: (value: TorrentProgress) => void
cb: (value: DownloadProgress) => void
) => () => Electron.IpcRenderer;
/* Catalogue */

View file

@ -1,9 +1,9 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { TorrentProgress } from "@types";
import type { DownloadProgress } from "@types";
export interface DownloadState {
lastPacket: TorrentProgress | null;
lastPacket: DownloadProgress | null;
gameId: number | null;
gamesWithDeletionInProgress: number[];
}
@ -18,7 +18,7 @@ export const downloadSlice = createSlice({
name: "download",
initialState,
reducers: {
setLastPacket: (state, action: PayloadAction<TorrentProgress>) => {
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
state.lastPacket = action.payload;
if (!state.gameId) state.gameId = action.payload.game.id;
},

View file

@ -9,9 +9,9 @@ import {
setGameDeleting,
removeGameFromDeleting,
} from "@renderer/features";
import type { GameShop, TorrentProgress } from "@types";
import type { DownloadProgress, GameShop } from "@types";
import { useDate } from "./use-date";
import { GameStatus, GameStatusHelper, formatBytes } from "@shared";
import { formatBytes } from "@shared";
export function useDownload() {
const { updateLibrary } = useLibrary();
@ -38,16 +38,16 @@ export function useDownload() {
return game;
});
const pauseDownload = (gameId: number) =>
window.electron.pauseGameDownload(gameId).then(() => {
dispatch(clearDownload());
updateLibrary();
});
const pauseDownload = async (gameId: number) => {
await window.electron.pauseGameDownload(gameId);
await updateLibrary();
dispatch(clearDownload());
};
const resumeDownload = (gameId: number) =>
window.electron.resumeGameDownload(gameId).then(() => {
updateLibrary();
});
const resumeDownload = async (gameId: number) => {
await window.electron.resumeGameDownload(gameId);
return updateLibrary();
};
const cancelDownload = (gameId: number) =>
window.electron.cancelGameDownload(gameId).then(() => {
@ -61,14 +61,8 @@ export function useDownload() {
updateLibrary();
});
const isVerifying = GameStatusHelper.isVerifying(
lastPacket?.game.status ?? null
);
const getETA = () => {
if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
return "";
}
if (lastPacket && lastPacket.timeRemaining < 0) return "";
try {
return formatDistance(
@ -81,14 +75,6 @@ export function useDownload() {
}
};
const getProgress = () => {
if (lastPacket?.game.status === GameStatus.CheckingFiles) {
return formatDownloadProgress(lastPacket?.game.fileVerificationProgress);
}
return formatDownloadProgress(lastPacket?.game.progress);
};
const deleteGame = (gameId: number) =>
window.electron
.cancelGameDownload(gameId)
@ -107,15 +93,9 @@ export function useDownload() {
};
return {
game: lastPacket?.game,
bytesDownloaded: lastPacket?.game.bytesDownloaded,
fileSize: lastPacket?.game.fileSize,
isVerifying,
gameId: lastPacket?.game.id,
downloadSpeed: `${formatBytes(lastPacket?.downloadSpeed ?? 0)}/s`,
progress: getProgress(),
numPeers: lastPacket?.numPeers,
numSeeds: lastPacket?.numSeeds,
progress: formatDownloadProgress(lastPacket?.game.progress ?? 0),
lastPacket,
eta: getETA(),
startDownload,
pauseDownload,
@ -125,6 +105,7 @@ export function useDownload() {
deleteGame,
isGameDeleting,
clearDownload: () => dispatch(clearDownload()),
setLastPacket: (packet: TorrentProgress) => dispatch(setLastPacket(packet)),
setLastPacket: (packet: DownloadProgress) =>
dispatch(setLastPacket(packet)),
};
}

View file

@ -26,10 +26,7 @@ export function Downloads() {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const {
game: gameDownloading,
progress,
numPeers,
numSeeds,
pauseDownload,
resumeDownload,
removeGameFromLibrary,

View file

@ -1,25 +1,25 @@
import { useTranslation } from "react-i18next";
import type { ShopDetails } from "@types";
import * as styles from "./game-details.css";
import { useContext } from "react";
import { gameDetailsContext } from "./game-details.context";
export interface DescriptionHeaderProps {
gameDetails: ShopDetails;
}
export function DescriptionHeader() {
const { shopDetails } = useContext(gameDetailsContext);
export function DescriptionHeader({ gameDetails }: DescriptionHeaderProps) {
const { t } = useTranslation("game_details");
if (!shopDetails) return null;
return (
<div className={styles.descriptionHeader}>
<section className={styles.descriptionHeaderInfo}>
<p>
{t("release_date", {
date: gameDetails?.release_date.date,
date: shopDetails?.release_date.date,
})}
</p>
<p>{t("publisher", { publisher: gameDetails.publishers[0] })}</p>
<p>{t("publisher", { publisher: shopDetails.publishers[0] })}</p>
</section>
</div>
);

View file

@ -1,37 +1,36 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import type { ShopDetails } from "@types";
import * as styles from "./gallery-slider.css";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "./game-details.context";
export interface GallerySliderProps {
gameDetails: ShopDetails;
}
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const mediaContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation("game_details");
const hasScreenshots = gameDetails && gameDetails.screenshots.length;
const hasMovies = gameDetails && gameDetails.movies?.length;
const hasScreenshots = shopDetails && shopDetails.screenshots.length;
const hasMovies = shopDetails && shopDetails.movies?.length;
const [mediaCount] = useState<number>(() => {
if (gameDetails.screenshots && gameDetails.movies) {
return gameDetails.screenshots.length + gameDetails.movies.length;
} else if (gameDetails.movies) {
return gameDetails.movies.length;
} else if (gameDetails.screenshots) {
return gameDetails.screenshots.length;
const mediaCount = useMemo(() => {
if (!shopDetails) return 0;
if (shopDetails.screenshots && shopDetails.movies) {
return shopDetails.screenshots.length + shopDetails.movies.length;
} else if (shopDetails.movies) {
return shopDetails.movies.length;
} else if (shopDetails.screenshots) {
return shopDetails.screenshots.length;
}
return 0;
});
}, [shopDetails]);
const [mediaIndex, setMediaIndex] = useState<number>(0);
const [mediaIndex, setMediaIndex] = useState(0);
const [showArrows, setShowArrows] = useState(false);
const showNextImage = () => {
@ -52,7 +51,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
useEffect(() => {
setMediaIndex(0);
}, [gameDetails]);
}, [shopDetails]);
useEffect(() => {
if (hasMovies && mediaContainerRef.current) {
@ -76,17 +75,17 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
}
}, [gameDetails, mediaIndex, mediaCount]);
}, [shopDetails, mediaIndex, mediaCount]);
const previews = useMemo(() => {
const screenshotPreviews =
gameDetails?.screenshots.map(({ id, path_thumbnail }) => ({
shopDetails?.screenshots.map(({ id, path_thumbnail }) => ({
id,
thumbnail: path_thumbnail,
})) ?? [];
if (gameDetails?.movies) {
const moviePreviews = gameDetails.movies.map(({ id, thumbnail }) => ({
if (shopDetails?.movies) {
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
id,
thumbnail,
}));
@ -95,7 +94,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
}
return screenshotPreviews;
}, [gameDetails]);
}, [shopDetails]);
return (
<>
@ -107,8 +106,8 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
className={styles.gallerySliderAnimationContainer}
ref={mediaContainerRef}
>
{gameDetails.movies &&
gameDetails.movies.map((video) => (
{shopDetails.movies &&
shopDetails.movies.map((video) => (
<video
key={video.id}
controls
@ -124,7 +123,7 @@ export function GallerySlider({ gameDetails }: GallerySliderProps) {
))}
{hasScreenshots &&
gameDetails.screenshots.map((image, i) => (
shopDetails.screenshots.map((image, i) => (
<img
key={image.id}
className={styles.gallerySliderMedia}

View file

@ -0,0 +1,206 @@
import { createContext, useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import type { Game, GameRepack, GameShop, ShopDetails } from "@types";
import { useTranslation } from "react-i18next";
import {
DODIInstallationGuide,
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
OnlineFixInstallationGuide,
RepacksModal,
} from "./modals";
export interface GameDetailsContext {
game: Game | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
objectID: string | undefined;
gameColor: string;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
openRepacksModal: () => void;
updateGame: () => Promise<void>;
}
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
shopDetails: null,
repacks: [],
gameTitle: "",
isGameRunning: false,
isLoading: false,
objectID: undefined,
gameColor: "",
setGameColor: () => {},
openRepacksModal: () => {},
updateGame: async () => {},
});
const { Provider } = gameDetailsContext;
export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
export interface GameDetailsContextProps {
children: React.ReactNode;
}
export function GameDetailsContextProvider({
children,
}: GameDetailsContextProps) {
const { objectID, shop } = useParams();
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [gameColor, setGameColor] = useState("");
const [showInstructionsModal, setShowInstructionsModal] = useState<
null | "onlinefix" | "DODI"
>(null);
const [isGameRunning, setisGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [searchParams] = useSearchParams();
const gameTitle = searchParams.get("title")!;
const { i18n } = useTranslation("game_details");
const dispatch = useAppDispatch();
const { startDownload, lastPacket } = useDownload();
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectID(objectID!)
.then((result) => setGame(result));
}, [setGame, objectID]);
const isGameDownloading = lastPacket?.game.id === game?.id;
useEffect(() => {
updateGame();
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.all([
window.electron.getGameShopDetails(
objectID!,
shop as GameShop,
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(gameTitle),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
})
.finally(() => {
setIsLoading(false);
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
useEffect(() => {
setGameDetails(null);
setGame(null);
setIsLoading(true);
setisGameRunning(false);
dispatch(setHeaderTitle(gameTitle));
}, [objectID, gameTitle, dispatch]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGameRunning) setisGameRunning(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGameRunning) setisGameRunning(true);
updateGame();
}
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [game?.id, isGameRunning, updateGame]);
const handleStartDownload = async (
repack: GameRepack,
downloadPath: string
) => {
await startDownload(
repack.id,
objectID!,
gameTitle,
shop as GameShop,
downloadPath
);
await updateGame();
setShowRepacksModal(false);
if (
repack.repacker === "onlinefix" &&
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("onlinefix");
} else if (
repack.repacker === "DODI" &&
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("DODI");
}
};
const openRepacksModal = () => setShowRepacksModal(true);
return (
<Provider
value={{
game,
shopDetails,
repacks,
gameTitle,
isGameRunning,
isLoading,
objectID,
gameColor,
setGameColor,
openRepacksModal,
updateGame,
}}
>
<>
<RepacksModal
visible={showRepacksModal}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"}
onClose={() => setShowInstructionsModal(null)}
/>
<DODIInstallationGuide
visible={showInstructionsModal === "DODI"}
onClose={() => setShowInstructionsModal(null)}
/>
{children}
</>
</Provider>
);
}

View file

@ -1,24 +1,11 @@
import Color from "color";
import { average } from "color.js";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { average } from "color.js";
import {
Steam250Game,
type Game,
type GameRepack,
type GameShop,
type ShopDetails,
} from "@types";
import { Steam250Game } from "@types";
import { Button } from "@renderer/components";
import { setHeaderTitle } from "@renderer/features";
import {
buildGameDetailsPath,
getSteamLanguage,
steamUrlBuilder,
} from "@renderer/helpers";
import { useAppDispatch, useDownload } from "@renderer/hooks";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -29,153 +16,34 @@ import { DescriptionHeader } from "./description-header";
import { GameDetailsSkeleton } from "./game-details-skeleton";
import * as styles from "./game-details.css";
import { HeroPanel } from "./hero";
import { RepacksModal } from "./repacks-modal";
import { vars } from "../../theme.css";
import {
DODIInstallationGuide,
DONT_SHOW_DODI_INSTRUCTIONS_KEY,
DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY,
OnlineFixInstallationGuide,
} from "./installation-guides";
import { GallerySlider } from "./gallery-slider";
import { Sidebar } from "./sidebar/sidebar";
import {
GameDetailsContextConsumer,
GameDetailsContextProvider,
} from "./game-details.context";
export function GameDetails() {
const { objectID, shop } = useParams();
const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [color, setColor] = useState({ dark: "", light: "" });
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [isGamePlaying, setIsGamePlaying] = useState(false);
const [showInstructionsModal, setShowInstructionsModal] = useState<
null | "onlinefix" | "DODI"
>(null);
const navigate = useNavigate();
const { objectID } = useParams();
const [searchParams] = useSearchParams();
const fromRandomizer = searchParams.get("fromRandomizer");
const title = searchParams.get("title")!;
const { t, i18n } = useTranslation("game_details");
const { t } = useTranslation("game_details");
const [showRepacksModal, setShowRepacksModal] = useState(false);
const dispatch = useAppDispatch();
const { game: gameDownloading, startDownload } = useDownload();
const heroImage = steamUrlBuilder.libraryHero(objectID!);
const handleHeroLoad = () => {
average(heroImage, { amount: 1, format: "hex" })
.then((color) => {
const darkColor = new Color(color).darken(0.6).toString() as string;
setColor({ light: color as string, dark: darkColor });
})
.catch(() => {});
};
const getGame = useCallback(() => {
window.electron
.getGameByObjectID(objectID!)
.then((result) => setGame(result));
}, [setGame, objectID]);
const navigate = useNavigate();
useEffect(() => {
getGame();
}, [getGame, gameDownloading?.id]);
useEffect(() => {
setGameDetails(null);
setGame(null);
setIsLoading(true);
setIsGamePlaying(false);
dispatch(setHeaderTitle(title));
setRandomGame(null);
window.electron.getRandomGame().then((randomGame) => {
setRandomGame(randomGame);
});
Promise.all([
window.electron.getGameShopDetails(
objectID!,
"steam",
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(title),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
})
.finally(() => {
setIsLoading(false);
});
getGame();
}, [getGame, dispatch, navigate, title, objectID, i18n.language]);
const isGameDownloading = gameDownloading?.id === game?.id;
useEffect(() => {
if (isGameDownloading)
setGame((prev) => {
if (prev === null || !gameDownloading?.status) return prev;
return { ...prev, status: gameDownloading?.status };
});
}, [isGameDownloading, gameDownloading?.status]);
useEffect(() => {
const listeners = [
window.electron.onGameClose(() => {
if (isGamePlaying) setIsGamePlaying(false);
}),
window.electron.onPlaytime((gameId) => {
if (gameId === game?.id) {
if (!isGamePlaying) setIsGamePlaying(true);
getGame();
}
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [game?.id, isGamePlaying, getGame]);
const handleStartDownload = async (
repack: GameRepack,
downloadPath: string
) => {
return startDownload(
repack.id,
objectID!,
title,
shop as GameShop,
downloadPath
).then(() => {
getGame();
setShowRepacksModal(false);
if (
repack.repacker === "onlinefix" &&
!window.localStorage.getItem(DONT_SHOW_ONLINE_FIX_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("onlinefix");
} else if (
repack.repacker === "DODI" &&
!window.localStorage.getItem(DONT_SHOW_DODI_INSTRUCTIONS_KEY)
) {
setShowInstructionsModal("DODI");
}
});
};
}, [objectID]);
const handleRandomizerClick = () => {
if (randomGame) {
@ -189,97 +57,95 @@ export function GameDetails() {
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<RepacksModal
visible={showRepacksModal}
repacks={repacks}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<GameDetailsContextProvider>
<GameDetailsContextConsumer>
{({ game, shopDetails, isLoading, setGameColor }) => {
const handleHeroLoad = async () => {
const output = await average(
steamUrlBuilder.libraryHero(objectID!),
{
amount: 1,
format: "hex",
}
);
<OnlineFixInstallationGuide
visible={showInstructionsModal === "onlinefix"}
onClose={() => setShowInstructionsModal(null)}
/>
setGameColor(output as string);
};
<DODIInstallationGuide
windowColor={color.light}
visible={showInstructionsModal === "DODI"}
onClose={() => setShowInstructionsModal(null)}
/>
return (
<SkeletonTheme
baseColor={vars.color.background}
highlightColor="#444"
>
{isLoading ? (
<GameDetailsSkeleton />
) : (
<section className={styles.container}>
<div className={styles.hero}>
<img
src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
alt={game?.title}
/>
</div>
</div>
</div>
{isLoading ? (
<GameDetailsSkeleton />
) : (
<section className={styles.container}>
<div className={styles.hero}>
<img
src={heroImage}
className={styles.heroImage}
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<img
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
alt={game?.title}
/>
</div>
</div>
</div>
<HeroPanel />
<HeroPanel
game={game}
color={color.dark}
objectID={objectID!}
title={title}
repacks={repacks}
openRepacksModal={() => setShowRepacksModal(true)}
getGame={getGame}
isGamePlaying={isGamePlaying}
/>
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
<DescriptionHeader />
<GallerySlider />
<div className={styles.descriptionContainer}>
<div className={styles.descriptionContent}>
{gameDetails && <DescriptionHeader gameDetails={gameDetails} />}
{gameDetails && <GallerySlider gameDetails={gameDetails} />}
<div
dangerouslySetInnerHTML={{
__html:
shopDetails?.about_the_game ?? t("no_shop_details"),
}}
className={styles.description}
/>
</div>
<div
dangerouslySetInnerHTML={{
__html: gameDetails?.about_the_game ?? t("no_shop_details"),
}}
className={styles.description}
/>
</div>
<Sidebar />
</div>
</section>
)}
<Sidebar
objectID={objectID!}
title={title}
gameDetails={gameDetails}
/>
</div>
</section>
)}
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
);
}}
</GameDetailsContextConsumer>
</GameDetailsContextProvider>
);
}

View file

@ -1,37 +1,14 @@
import { GameStatus, GameStatusHelper } from "@shared";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import type { Game, GameRepack } from "@types";
import { useState } from "react";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "../game-details.context";
export interface HeroPanelActionsProps {
game: Game | null;
repacks: GameRepack[];
isGamePlaying: boolean;
isGameDownloading: boolean;
objectID: string;
title: string;
openRepacksModal: () => void;
openBinaryNotFoundModal: () => void;
getGame: () => void;
}
export function HeroPanelActions({
game,
isGamePlaying,
isGameDownloading,
repacks,
objectID,
title,
openRepacksModal,
openBinaryNotFoundModal,
getGame,
}: HeroPanelActionsProps) {
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
useState(false);
@ -43,6 +20,16 @@ export function HeroPanelActions({
isGameDeleting,
} = useDownload();
const {
game,
repacks,
isGameRunning,
objectID,
gameTitle,
openRepacksModal,
updateGame,
} = useContext(gameDetailsContext);
const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details");
@ -86,15 +73,15 @@ export function HeroPanelActions({
const gameExecutablePath = await selectGameExecutable();
await window.electron.addGameToLibrary(
objectID,
title,
objectID!,
gameTitle,
"steam",
gameExecutablePath
);
}
updateLibrary();
getGame();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
@ -145,59 +132,14 @@ export function HeroPanelActions({
</Button>
);
if (game && isGameDownloading) {
if (game?.progress === 1) {
return (
<>
<Button
onClick={() => pauseDownload(game.id)}
theme="outline"
className={styles.heroPanelAction}
>
{t("pause")}
</Button>
<Button
onClick={() => cancelDownload(game.id)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === GameStatus.Paused) {
return (
<>
<Button
onClick={() => resumeDownload(game.id)}
theme="outline"
className={styles.heroPanelAction}
>
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(getGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (
GameStatusHelper.isReady(game?.status ?? null) ||
(game && !game.status)
) {
return (
<>
{GameStatusHelper.isReady(game?.status ?? null) ? (
{game?.progress === 1 ? (
<Button
onClick={openGameInstaller}
theme="outline"
disabled={deleting || isGamePlaying}
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("install")}
@ -206,7 +148,7 @@ export function HeroPanelActions({
toggleGameOnLibraryButton
)}
{isGamePlaying ? (
{isGameRunning ? (
<Button
onClick={closeGame}
theme="outline"
@ -219,7 +161,7 @@ export function HeroPanelActions({
<Button
onClick={openGame}
theme="outline"
disabled={deleting || isGamePlaying}
disabled={deleting || isGameRunning}
className={styles.heroPanelAction}
>
{t("play")}
@ -229,7 +171,49 @@ export function HeroPanelActions({
);
}
if (game?.status === GameStatus.Cancelled) {
if (game?.status === "active") {
return (
<>
<Button
onClick={() => pauseDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("pause")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "paused") {
return (
<>
<Button
onClick={() => resumeDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("resume")}
</Button>
<Button
onClick={() => cancelDownload(game.id).then(updateGame)}
theme="outline"
className={styles.heroPanelAction}
>
{t("cancel")}
</Button>
</>
);
}
if (game?.status === "removed") {
return (
<>
<Button
@ -240,8 +224,9 @@ export function HeroPanelActions({
>
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGameFromLibrary(game.id).then(getGame)}
onClick={() => removeGameFromLibrary(game.id).then(updateGame)}
theme="outline"
disabled={deleting}
className={styles.heroPanelAction}

View file

@ -1,22 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Game } from "@types";
import { useDate } from "@renderer/hooks";
import { gameDetailsContext } from "../game-details.context";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export interface HeroPanelPlaytimeProps {
game: Game;
isGamePlaying: boolean;
}
export function HeroPanelPlaytime({
game,
isGamePlaying,
}: HeroPanelPlaytimeProps) {
export function HeroPanelPlaytime() {
const [lastTimePlayed, setLastTimePlayed] = useState("");
const { game, isGameRunning } = useContext(gameDetailsContext);
const { i18n, t } = useTranslation("game_details");
const { formatDistance } = useDate();
@ -52,8 +46,8 @@ export function HeroPanelPlaytime({
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
if (!game.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game.title })}</p>;
if (!game?.lastTimePlayed) {
return <p>{t("not_played_yet", { title: game?.title })}</p>;
}
return (
@ -64,7 +58,7 @@ export function HeroPanelPlaytime({
})}
</p>
{isGamePlaying ? (
{isGameRunning ? (
<p>{t("playing_now")}</p>
) : (
<p>

View file

@ -1,72 +1,48 @@
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Color from "color";
import { useDownload } from "@renderer/hooks";
import type { Game, GameRepack } from "@types";
import { formatDownloadProgress } from "@renderer/helpers";
import { HeroPanelActions } from "./hero-panel-actions";
import { Downloader, GameStatus, GameStatusHelper, formatBytes } from "@shared";
import { Downloader, formatBytes } from "@shared";
import { BinaryNotFoundModal } from "../../shared-modals/binary-not-found-modal";
import * as styles from "./hero-panel.css";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "../game-details.context";
export interface HeroPanelProps {
game: Game | null;
color: string;
isGamePlaying: boolean;
objectID: string;
title: string;
repacks: GameRepack[];
openRepacksModal: () => void;
getGame: () => void;
}
export function HeroPanel({
game,
color,
repacks,
objectID,
title,
isGamePlaying,
openRepacksModal,
getGame,
}: HeroPanelProps) {
export function HeroPanel() {
const { t } = useTranslation("game_details");
const { game, repacks, gameColor } = useContext(gameDetailsContext);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const {
game: gameDownloading,
progress,
eta,
numPeers,
numSeeds,
isGameDeleting,
} = useDownload();
const isGameDownloading =
gameDownloading?.id === game?.id &&
GameStatusHelper.isDownloading(game?.status ?? null);
const { progress, eta, lastPacket, isGameDeleting } = useDownload();
const finalDownloadSize = useMemo(() => {
if (!game) return "N/A";
if (game.fileSize) return formatBytes(game.fileSize);
if (gameDownloading?.fileSize && isGameDownloading)
return formatBytes(gameDownloading.fileSize);
if (lastPacket?.game.fileSize && game?.status === "active")
return formatBytes(lastPacket?.game.fileSize);
return game.repack?.fileSize ?? "N/A";
}, [game, isGameDownloading, gameDownloading]);
}, [game, lastPacket?.game]);
const getInfo = () => {
if (isGameDeleting(game?.id ?? -1)) {
return <p>{t("deleting")}</p>;
}
if (isGameDeleting(game?.id ?? -1)) return <p>{t("deleting")}</p>;
if (game?.progress === 1) return <HeroPanelPlaytime />;
if (game?.status === "active") {
if (lastPacket?.downloadingMetadata) {
return <p>{t("downloading_metadata")}</p>;
}
if (isGameDownloading && gameDownloading?.status) {
return (
<>
<p className={styles.downloadDetailsRow}>
@ -74,33 +50,25 @@ export function HeroPanel({
{eta && <small>{t("eta", { eta })}</small>}
</p>
{gameDownloading.status !== GameStatus.Downloading ? (
<>
<p>{t(gameDownloading.status)}</p>
{eta && <small>{t("eta", { eta })}</small>}
</>
) : (
<p className={styles.downloadDetailsRow}>
{formatBytes(gameDownloading.bytesDownloaded)} /{" "}
{finalDownloadSize}
<p className={styles.downloadDetailsRow}>
{formatBytes(lastPacket?.game?.bytesDownloaded ?? 0)} /{" "}
{finalDownloadSize}
{game?.downloader === Downloader.Torrent && (
<small>
{game?.downloader === Downloader.Torrent &&
`${numPeers} peers / ${numSeeds} seeds`}
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
</p>
)}
)}
</p>
</>
);
}
if (game?.status === GameStatus.Paused) {
if (game?.status === "paused") {
const formattedProgress = formatDownloadProgress(game.progress);
return (
<>
<p>
{t("paused_progress", {
progress: formatDownloadProgress(game.progress),
})}
</p>
<p>{t("paused_progress", { progress: formattedProgress })}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
</p>
@ -108,10 +76,6 @@ export function HeroPanel({
);
}
if (game && GameStatusHelper.isReady(game?.status ?? GameStatus.Finished)) {
return <HeroPanelPlaytime game={game} isGamePlaying={isGamePlaying} />;
}
const [latestRepack] = repacks;
if (latestRepack) {
@ -129,6 +93,10 @@ export function HeroPanel({
return <p>{t("no_downloads")}</p>;
};
const backgroundColor = gameColor
? (new Color(gameColor).darken(0.6).toString() as string)
: "";
return (
<>
<BinaryNotFoundModal
@ -136,19 +104,11 @@ export function HeroPanel({
onClose={() => setShowBinaryNotFoundModal(false)}
/>
<div style={{ backgroundColor: color }} className={styles.panel}>
<div style={{ backgroundColor }} className={styles.panel}>
<div className={styles.content}>{getInfo()}</div>
<div className={styles.actions}>
<HeroPanelActions
game={game}
repacks={repacks}
objectID={objectID}
title={title}
getGame={getGame}
openRepacksModal={openRepacksModal}
openBinaryNotFoundModal={() => setShowBinaryNotFoundModal(true)}
isGamePlaying={isGamePlaying}
isGameDownloading={isGameDownloading}
/>
</div>
</div>

View file

@ -0,0 +1,3 @@
export * from "./installation-guides";
export * from "./repacks-modal";
export * from "./select-folder-modal";

View file

@ -1,4 +1,4 @@
import { vars } from "../../../theme.css";
import { vars } from "../../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
export const slideIn = keyframes({

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useContext, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button, CheckboxField, Modal } from "@renderer/components";
@ -7,18 +7,19 @@ import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./dodi-installation-guide.css";
import { ArrowUpIcon } from "@primer/octicons-react";
import { DONT_SHOW_DODI_INSTRUCTIONS_KEY } from "./constants";
import { gameDetailsContext } from "../../game-details.context";
export interface DODIInstallationGuideProps {
windowColor: string;
visible: boolean;
onClose: () => void;
}
export function DODIInstallationGuide({
windowColor,
visible,
onClose,
}: DODIInstallationGuideProps) {
const { gameColor } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const [dontShowAgain, setDontShowAgain] = useState(false);
@ -53,7 +54,7 @@ export function DODIInstallationGuide({
<div
className={styles.windowContainer}
style={{ backgroundColor: windowColor }}
style={{ backgroundColor: gameColor }}
>
<div className={styles.windowContent}>
<ArrowUpIcon size={24} />

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT } from "../../../theme.css";
import { SPACING_UNIT } from "../../../../theme.css";
import { style } from "@vanilla-extract/css";
export const passwordField = style({

View file

@ -1,5 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const repacks = style({
display: "flex",

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
@ -6,20 +6,19 @@ import type { GameRepack } from "@types";
import * as styles from "./repacks-modal.css";
import { SPACING_UNIT } from "../../theme.css";
import { SPACING_UNIT } from "../../../theme.css";
import { format } from "date-fns";
import { SelectFolderModal } from "./select-folder-modal";
import { gameDetailsContext } from "../game-details.context";
export interface RepacksModalProps {
visible: boolean;
repacks: GameRepack[];
startDownload: (repack: GameRepack, downloadPath: string) => Promise<void>;
onClose: () => void;
}
export function RepacksModal({
visible,
repacks,
startDownload,
onClose,
}: RepacksModalProps) {
@ -27,6 +26,8 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const { repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
useEffect(() => {

View file

@ -1,5 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const container = style({
display: "flex",

View file

@ -1,13 +1,14 @@
import { Button, Link, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DiskSpace } from "check-disk-space";
import * as styles from "./select-folder-modal.css";
import { Button, Link, Modal, TextField } from "@renderer/components";
import { DownloadIcon } from "@primer/octicons-react";
import { formatBytes } from "@shared";
import type { GameRepack } from "@types";
export interface SelectFolderModalProps {
visible: boolean;
onClose: () => void;

View file

@ -1,22 +1,13 @@
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { HowLongToBeatSection } from "./how-long-to-beat-section";
import type {
HowLongToBeatCategory,
ShopDetails,
SteamAppDetails,
} from "@types";
import type { HowLongToBeatCategory, SteamAppDetails } from "@types";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import * as styles from "./sidebar.css";
import { gameDetailsContext } from "../game-details.context";
export interface SidebarProps {
objectID: string;
title: string;
gameDetails: ShopDetails | null;
}
export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{
isLoading: boolean;
data: HowLongToBeatCategory[] | null;
@ -25,20 +16,24 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
useEffect(() => {
setHowLongToBeat({ isLoading: true, data: null });
if (objectID) {
setHowLongToBeat({ isLoading: true, data: null });
window.electron
.getHowLongToBeat(objectID, "steam", title)
.then((howLongToBeat) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}, [objectID, title]);
window.electron
.getHowLongToBeat(objectID, "steam", gameTitle)
.then((howLongToBeat) => {
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
})
.catch(() => {
setHowLongToBeat({ isLoading: false, data: null });
});
}
}, [objectID, gameTitle]);
return (
<aside className={styles.contentSidebar}>
@ -73,9 +68,9 @@ export function Sidebar({ objectID, title, gameDetails }: SidebarProps) {
className={styles.requirementsDetails}
dangerouslySetInnerHTML={{
__html:
gameDetails?.pc_requirements?.[activeRequirement] ??
shopDetails?.pc_requirements?.[activeRequirement] ??
t(`no_${activeRequirement}_requirements`, {
title,
gameTitle,
}),
}}
/>