diff --git a/package.json b/package.json index 09fe2cff..5c193ca4 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@electron-toolkit/tsconfig": "^1.0.1", "@swc/core": "^1.4.16", "@types/auto-launch": "^5.0.5", + "@types/color": "^3.0.6", "@types/jsdom": "^21.1.6", "@types/lodash-es": "^4.17.12", "@types/node": "^20.12.7", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 72b2b4cf..48407b32 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -19,7 +19,8 @@ "filter": "Filter library", "home": "Home", "queued": "{{title}} (Queued)", - "game_has_no_executable": "Game has no executable selected" + "game_has_no_executable": "Game has no executable selected", + "sign_in": "Sign in" }, "header": { "search": "Search games", @@ -232,7 +233,6 @@ "amount_hours": "{{amount}} hours", "amount_minutes": "{{amount}} minutes", "last_time_played": "Last played {{period}}", - "sign_out": "Sign out", "activity": "Recent activity", "library": "Library", "total_play_time": "Total playtime: {{amount}}", @@ -244,9 +244,10 @@ "edit_profile": "Edit Profile", "saved_successfully": "Saved successfully", "try_again": "Please, try again", - "signout_modal_title": "Are you sure?", + "sign_out_modal_title": "Are you sure?", "cancel": "Cancel", - "signout": "Sign Out", - "successfully_signed_out": "Successfully signed out" + "successfully_signed_out": "Successfully signed out", + "sign_out": "Sign out", + "playing_for": "Playing for {{amount}}" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index e658e656..739c573c 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -19,7 +19,8 @@ "filter": "Filtrar biblioteca", "home": "Início", "queued": "{{title}} (Na fila)", - "game_has_no_executable": "Jogo não possui executável selecionado" + "game_has_no_executable": "Jogo não possui executável selecionado", + "sign_in": "Login" }, "header": { "search": "Buscar jogos", @@ -232,7 +233,6 @@ "amount_hours": "{{amount}} horas", "amount_minutes": "{{amount}} minutos", "last_time_played": "Jogou {{period}}", - "sign_out": "Sair da conta", "activity": "Atividade recente", "library": "Biblioteca", "total_play_time": "Tempo total de jogo: {{amount}}", @@ -245,8 +245,9 @@ "saved_successfully": "Salvo com sucesso", "try_again": "Por favor, tente novamente", "cancel": "Cancelar", - "signout": "Sair da conta", - "signout_modal_title": "Tem certeza?", - "successfully_signed_out": "Deslogado com sucesso" + "successfully_signed_out": "Deslogado com sucesso", + "sign_out": "Sair da conta", + "sign_out_modal_title": "Tem certeza?", + "playing_for": "Jogando por {{amount}}" } } diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index 5a485a99..68d18c1b 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -27,8 +27,6 @@ const updateProfile = async ( displayName: string, newProfileImagePath: string | null ): Promise => { - console.log(newProfileImagePath); - if (!newProfileImagePath) { return (await patchUserProfile(displayName)).data; } diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index de3af727..fc97ff40 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -5,6 +5,7 @@ import { gameRepository } from "@main/repository"; import { getProcesses } from "@main/helpers"; import { WindowManager } from "./window-manager"; import { createGame, updateGamePlaytime } from "./library-sync"; +import { GameRunning } from "@types"; const gamesPlaytime = new Map< number, @@ -46,10 +47,6 @@ export const watchProcesses = async () => { const zero = gamePlaytime.lastTick; const delta = performance.now() - zero; - if (WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send("on-playtime", game.id); - } - await gameRepository.update(game.id, { playTimeInMilliseconds: game.playTimeInMilliseconds + delta, lastTimePlayed: new Date(), @@ -92,10 +89,20 @@ export const watchProcesses = async () => { gameRepository.update({ objectID: game.objectID }, { remoteId }); }); } - - if (WindowManager.mainWindow) { - WindowManager.mainWindow.webContents.send("on-game-close", game.id); - } } } + + if (WindowManager.mainWindow) { + const gamesRunning = Array.from(gamesPlaytime.entries()).map((entry) => { + return { + id: entry[0], + sessionDurationInMillis: performance.now() - entry[1].firstTick, + }; + }); + + WindowManager.mainWindow.webContents.send( + "on-games-running", + gamesRunning as Pick[] + ); + } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0cacafe3..493a3795 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import type { UserPreferences, AppUpdaterEvent, StartGameDownloadPayload, + GameRunning, } from "@types"; contextBridge.exposeInMainWorld("electron", { @@ -84,17 +85,15 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("deleteGameFolder", gameId), getGameByObjectID: (objectID: string) => ipcRenderer.invoke("getGameByObjectID", objectID), - onPlaytime: (cb: (gameId: number) => void) => { - const listener = (_event: Electron.IpcRendererEvent, gameId: number) => - cb(gameId); - ipcRenderer.on("on-playtime", listener); - return () => ipcRenderer.removeListener("on-playtime", listener); - }, - onGameClose: (cb: (gameId: number) => void) => { - const listener = (_event: Electron.IpcRendererEvent, gameId: number) => - cb(gameId); - ipcRenderer.on("on-game-close", listener); - return () => ipcRenderer.removeListener("on-game-close", listener); + onGamesRunning: ( + cb: ( + gamesRunning: Pick[] + ) => void + ) => { + const listener = (_event: Electron.IpcRendererEvent, gamesRunning) => + cb(gamesRunning); + ipcRenderer.on("on-games-running", listener); + return () => ipcRenderer.removeListener("on-games-running", listener); }, onLibraryBatchComplete: (cb: () => void) => { const listener = (_event: Electron.IpcRendererEvent) => cb(); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 45a0cb43..d98f1e1f 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -22,6 +22,7 @@ import { closeToast, setUserDetails, setProfileBackground, + setGameRunning, } from "@renderer/features"; import { useTranslation } from "react-i18next"; @@ -31,7 +32,7 @@ export interface AppProps { export function App() { const contentRef = useRef(null); - const { updateLibrary } = useLibrary(); + const { updateLibrary, library } = useLibrary(); const { t } = useTranslation("app"); @@ -110,6 +111,32 @@ export function App() { }); }, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); + useEffect(() => { + const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { + if (gamesRunning.length) { + const lastGame = gamesRunning[gamesRunning.length - 1]; + const libraryGame = library.find( + (library) => library.id === lastGame.id + ); + + if (libraryGame) { + dispatch( + setGameRunning({ + ...libraryGame, + sessionDurationInMillis: lastGame.sessionDurationInMillis, + }) + ); + return; + } + } + dispatch(setGameRunning(null)); + }); + + return () => { + unsubscribe(); + }; + }, [dispatch, library]); + useEffect(() => { const listeners = [ window.electron.onSignIn(onSignIn), diff --git a/src/renderer/src/components/sidebar/sidebar-profile.css.ts b/src/renderer/src/components/sidebar/sidebar-profile.css.ts index 9681c866..c5347c26 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.css.ts +++ b/src/renderer/src/components/sidebar/sidebar-profile.css.ts @@ -20,6 +20,7 @@ export const profileButtonContent = style({ alignItems: "center", gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, height: "40px", + width: "100%", }); export const profileAvatar = style({ @@ -39,6 +40,8 @@ export const profileButtonInformation = style({ display: "flex", flexDirection: "column", alignItems: "flex-start", + flex: "1", + minWidth: 0, }); export const statusBadge = style({ @@ -55,4 +58,9 @@ export const statusBadge = style({ export const profileButtonTitle = style({ fontWeight: "bold", fontSize: vars.size.body, + width: "100%", + textAlign: "left", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index c86aecb7..914481b0 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -2,14 +2,19 @@ import { useNavigate } from "react-router-dom"; import { PersonIcon } from "@primer/octicons-react"; import * as styles from "./sidebar-profile.css"; -import { useUserDetails } from "@renderer/hooks"; +import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; export function SidebarProfile() { const navigate = useNavigate(); + const { t } = useTranslation("sidebar"); + const { userDetails, profileBackground } = useUserDetails(); + const { gameRunning } = useAppSelector((state) => state.gameRunning); + const handleButtonClick = () => { if (userDetails === null) { window.electron.openAuthWindow(); @@ -46,9 +51,24 @@ export function SidebarProfile() {

- {userDetails ? userDetails.displayName : "Sign in"} + {userDetails ? userDetails.displayName : t("sign_in")}

+ + {userDetails && gameRunning && ( +
+ {gameRunning.title} +
+ )}
+ + {userDetails && gameRunning && ( + {gameRunning.title} + )} ); diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index ad32f987..19d2cc72 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -109,20 +109,19 @@ export function GameDetailsContextProvider({ }, [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(); - } - }), - ]; + const unsubscribe = window.electron.onGamesRunning((gamesIds) => { + const updatedIsGameRunning = + !!game?.id && + !!gamesIds.find((gameRunning) => gameRunning.id == game.id); + if (isGameRunning != updatedIsGameRunning) { + updateGame(); + } + + setisGameRunning(updatedIsGameRunning); + }); return () => { - listeners.forEach((unsubscribe) => unsubscribe()); + unsubscribe(); }; }, [game?.id, isGameRunning, updateGame]); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index b7707d6f..64f8d0a3 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -71,8 +71,11 @@ declare global { removeGame: (gameId: number) => Promise; deleteGameFolder: (gameId: number) => Promise; getGameByObjectID: (objectID: string) => Promise; - onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer; - onGameClose: (cb: (gameId: number) => void) => () => Electron.IpcRenderer; + onGamesRunning: ( + cb: ( + gamesRunning: Pick[] + ) => void + ) => () => Electron.IpcRenderer; onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer; /* User preferences */ diff --git a/src/renderer/src/features/index.ts b/src/renderer/src/features/index.ts index f3132520..fdc23e68 100644 --- a/src/renderer/src/features/index.ts +++ b/src/renderer/src/features/index.ts @@ -5,3 +5,4 @@ export * from "./download-slice"; export * from "./window-slice"; export * from "./toast-slice"; export * from "./user-details-slice"; +export * from "./running-game-slice"; diff --git a/src/renderer/src/features/running-game-slice.ts b/src/renderer/src/features/running-game-slice.ts new file mode 100644 index 00000000..b3fb0a9d --- /dev/null +++ b/src/renderer/src/features/running-game-slice.ts @@ -0,0 +1,22 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { GameRunning } from "@types"; + +export interface GameRunningState { + gameRunning: GameRunning | null; +} + +const initialState: GameRunningState = { + gameRunning: null, +}; + +export const gameRunningSlice = createSlice({ + name: "running-game", + initialState, + reducers: { + setGameRunning: (state, action: PayloadAction) => { + state.gameRunning = action.payload; + }, + }, +}); + +export const { setGameRunning } = gameRunningSlice.actions; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 19d1969c..d37612d4 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -43,5 +43,5 @@ export const buildGameDetailsPath = ( return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`; }; -export const darkenColor = (color: string, amount: number) => - new Color(color).darken(amount).toString(); +export const darkenColor = (color: string, amount: number, alpha: number = 1) => + new Color(color).darken(amount).alpha(alpha).toString(); diff --git a/src/renderer/src/hooks/use-date.ts b/src/renderer/src/hooks/use-date.ts index f5e8204e..01f55610 100644 --- a/src/renderer/src/hooks/use-date.ts +++ b/src/renderer/src/hooks/use-date.ts @@ -1,4 +1,4 @@ -import { formatDistance } from "date-fns"; +import { formatDistance, subMilliseconds } from "date-fns"; import type { FormatDistanceOptions } from "date-fns"; import { ptBR, @@ -52,5 +52,20 @@ export function useDate() { return ""; } }, + + formatDiffInMillis: ( + millis: number, + baseDate: string | number | Date, + options?: FormatDistanceOptions + ) => { + try { + return formatDistance(subMilliseconds(new Date(), millis), baseDate, { + ...options, + locale: getDateLocale(), + }); + } catch (err) { + return ""; + } + }, }; } diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 75de473f..1d8257f4 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -36,8 +36,7 @@ export function useUserDetails() { format: "hex", }); - const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8)})`; - + const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; dispatch(setProfileBackground(profileBackground)); window.localStorage.setItem( @@ -45,9 +44,13 @@ export function useUserDetails() { JSON.stringify({ ...userDetails, profileBackground }) ); } else { - dispatch(setProfileBackground(null)); + const profileBackground = `#151515B3`; + dispatch(setProfileBackground(profileBackground)); - window.localStorage.setItem("userDetails", JSON.stringify(userDetails)); + window.localStorage.setItem( + "userDetails", + JSON.stringify({ ...userDetails, profileBackground }) + ); } }, [dispatch] diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index 0c32f989..ad620868 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -6,9 +6,14 @@ import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; -import { useDate, useToast, useUserDetails } from "@renderer/hooks"; +import { + useAppSelector, + useDate, + useToast, + useUserDetails, +} from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath } from "@renderer/helpers"; +import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; import { Button } from "@renderer/components"; import { UserEditProfileModal } from "./user-edit-modal"; @@ -33,6 +38,8 @@ export function UserContent({ const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false); + const { gameRunning } = useAppSelector((state) => state.gameRunning); + const navigate = useNavigate(); const numberFormatter = useMemo(() => { @@ -41,7 +48,7 @@ export function UserContent({ }); }, [i18n.language]); - const { formatDistance } = useDate(); + const { formatDistance, formatDiffInMillis } = useDate(); const formatPlayTime = () => { const seconds = userProfile.libraryGames.reduce( @@ -102,10 +109,32 @@ export function UserContent({
+ {gameRunning && isMe && ( +
+ )} + +
+
{userProfile.profileImageUrl ? (

{userProfile.displayName}

+ {isMe && gameRunning && ( +
+
+

{gameRunning.title}

+
+ + {t("playing_for", { + amount: formatDiffInMillis( + gameRunning.sessionDurationInMillis, + new Date() + ), + })} + +
+ )}
{isMe && ( -
+
<>