diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 833685d7..4bfd8375 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -819,6 +819,16 @@ "learn_more": "Learn More", "debrid_description": "Download up to 4x faster with Nimbus" }, + "game_launcher": { + "launching": "Launching...", + "launching_base": "Launching", + "open_hydra": "Open Hydra", + "playtime": "Playtime", + "amount_hours": "{{amount}} hours", + "amount_minutes": "{{amount}} minutes", + "amount_hours_short": "{{amount}}h", + "amount_minutes_short": "{{amount}}m" + }, "notifications_page": { "title": "Notifications", "mark_all_as_read": "Mark all as read", diff --git a/src/main/events/library/open-game.ts b/src/main/events/library/open-game.ts index 64e3d5fb..5bfd6433 100644 --- a/src/main/events/library/open-game.ts +++ b/src/main/events/library/open-game.ts @@ -1,10 +1,11 @@ import { registerEvent } from "../register-event"; import { shell } from "electron"; -import { spawn } from "child_process"; +import { spawn } from "node:child_process"; import { parseExecutablePath } from "../helpers/parse-executable-path"; import { gamesSublevel, levelKeys } from "@main/level"; import { GameShop } from "@types"; import { parseLaunchOptions } from "../helpers/parse-launch-options"; +import { WindowManager } from "@main/services"; const openGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -28,6 +29,9 @@ const openGame = async ( launchOptions, }); + // Always show the launcher window when launching a game + WindowManager.createGameLauncherWindow(shop, objectId); + if (parsedParams.length === 0) { shell.openPath(parsedPath); return; diff --git a/src/main/events/misc/close-game-launcher-window.ts b/src/main/events/misc/close-game-launcher-window.ts new file mode 100644 index 00000000..cc6c1e4c --- /dev/null +++ b/src/main/events/misc/close-game-launcher-window.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { WindowManager } from "@main/services"; + +const closeGameLauncherWindow = async () => { + WindowManager.closeGameLauncherWindow(); +}; + +registerEvent("closeGameLauncherWindow", closeGameLauncherWindow); diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts index 354e6687..029ae5c6 100644 --- a/src/main/events/misc/index.ts +++ b/src/main/events/misc/index.ts @@ -1,12 +1,15 @@ import "./can-install-common-redist"; import "./check-homebrew-folder-exists"; +import "./close-game-launcher-window"; import "./delete-temp-file"; +import "./show-game-launcher-window"; import "./get-hydra-decky-plugin-info"; import "./hydra-api-call"; import "./install-common-redist"; import "./install-hydra-decky-plugin"; import "./open-checkout"; import "./open-external"; +import "./open-main-window"; import "./save-temp-file"; import "./show-item-in-folder"; import "./show-open-dialog"; diff --git a/src/main/events/misc/open-main-window.ts b/src/main/events/misc/open-main-window.ts new file mode 100644 index 00000000..4a700edc --- /dev/null +++ b/src/main/events/misc/open-main-window.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { WindowManager } from "@main/services"; + +const openMainWindow = async () => { + WindowManager.openMainWindow(); +}; + +registerEvent("openMainWindow", openMainWindow); diff --git a/src/main/events/misc/show-game-launcher-window.ts b/src/main/events/misc/show-game-launcher-window.ts new file mode 100644 index 00000000..5fc53874 --- /dev/null +++ b/src/main/events/misc/show-game-launcher-window.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { WindowManager } from "@main/services"; + +const showGameLauncherWindow = async () => { + WindowManager.showGameLauncherWindow(); +}; + +registerEvent("showGameLauncherWindow", showGameLauncherWindow); diff --git a/src/main/index.ts b/src/main/index.ts index e0415884..f712b7a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,7 +15,7 @@ import { import resources from "@locales"; import { PythonRPC } from "./services/python-rpc"; import { db, gamesSublevel, levelKeys } from "./level"; -import { GameShop } from "@types"; +import { GameShop, UserPreferences } from "@types"; import { parseExecutablePath } from "./events/helpers/parse-executable-path"; import { parseLaunchOptions } from "./events/helpers/parse-launch-options"; import { loadState } from "./main"; @@ -144,16 +144,19 @@ app.whenReady().then(async () => { if (language) i18n.changeLanguage(language); - if (!process.argv.includes("--hidden")) { + // Check if starting from a "run" deep link - don't show main window in that case + const deepLinkArg = process.argv.find((arg) => + arg.startsWith("hydralauncher://") + ); + const isRunDeepLink = deepLinkArg?.startsWith("hydralauncher://run"); + + if (!process.argv.includes("--hidden") && !isRunDeepLink) { WindowManager.createMainWindow(); } WindowManager.createNotificationWindow(); WindowManager.createSystemTray(language || "en"); - const deepLinkArg = process.argv.find((arg) => - arg.startsWith("hydralauncher://") - ); if (deepLinkArg) { handleDeepLinkPath(deepLinkArg); } @@ -172,6 +175,19 @@ const handleRunGame = async (shop: GameShop, objectId: string) => { return; } + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + + // Always show the launcher window + await WindowManager.createGameLauncherWindow(shop, objectId); + + // Only open main window if setting is disabled + if (!userPreferences?.hideToTrayOnGameStart) { + WindowManager.createMainWindow(); + } + const parsedPath = parseExecutablePath(game.executablePath); const parsedParams = parseLaunchOptions(game.launchOptions); @@ -237,17 +253,23 @@ const handleDeepLinkPath = (uri?: string) => { }; app.on("second-instance", (_event, commandLine) => { - // Someone tried to run a second instance, we should focus our window. - if (WindowManager.mainWindow) { - if (WindowManager.mainWindow.isMinimized()) - WindowManager.mainWindow.restore(); + const deepLink = commandLine.pop(); - WindowManager.mainWindow.focus(); - } else { - WindowManager.createMainWindow(); + // Check if this is a "run" deep link - don't show main window in that case + const isRunDeepLink = deepLink?.startsWith("hydralauncher://run"); + + if (!isRunDeepLink) { + if (WindowManager.mainWindow) { + if (WindowManager.mainWindow.isMinimized()) + WindowManager.mainWindow.restore(); + + WindowManager.mainWindow.focus(); + } else { + WindowManager.createMainWindow(); + } } - handleDeepLinkPath(commandLine.pop()); + handleDeepLinkPath(deepLink); }); app.on("open-url", (_event, url) => { diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index ee19294d..912f9e59 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -204,6 +204,9 @@ function onOpenGame(game: Game) { lastSyncTick: now, }); + // Close the launcher window when game starts + WindowManager.closeGameLauncherWindow(); + // Hide Hydra to tray on game startup if enabled db.get(levelKeys.userPreferences, { valueEncoding: "json", diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 813a72fa..61dba280 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -30,6 +30,7 @@ import { logger } from "./logger"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; public static notificationWindow: Electron.BrowserWindow | null = null; + public static gameLauncherWindow: Electron.BrowserWindow | null = null; private static readonly editorWindows: Map = new Map(); @@ -516,6 +517,84 @@ export class WindowManager { } } + private static readonly GAME_LAUNCHER_WINDOW_WIDTH = 550; + private static readonly GAME_LAUNCHER_WINDOW_HEIGHT = 320; + + public static async createGameLauncherWindow(shop: string, objectId: string) { + if (this.gameLauncherWindow) { + this.gameLauncherWindow.close(); + this.gameLauncherWindow = null; + } + + const display = screen.getPrimaryDisplay(); + const { width: displayWidth, height: displayHeight } = display.bounds; + + const x = Math.round((displayWidth - this.GAME_LAUNCHER_WINDOW_WIDTH) / 2); + const y = Math.round( + (displayHeight - this.GAME_LAUNCHER_WINDOW_HEIGHT) / 2 + ); + + this.gameLauncherWindow = new BrowserWindow({ + width: this.GAME_LAUNCHER_WINDOW_WIDTH, + height: this.GAME_LAUNCHER_WINDOW_HEIGHT, + x, + y, + resizable: false, + maximizable: false, + minimizable: false, + fullscreenable: false, + frame: false, + backgroundColor: "#1c1c1c", + icon, + skipTaskbar: false, + webPreferences: { + preload: path.join(__dirname, "../preload/index.mjs"), + sandbox: false, + }, + show: false, + }); + + this.gameLauncherWindow.removeMenu(); + + this.loadWindowURL( + this.gameLauncherWindow, + `game-launcher?shop=${shop}&objectId=${objectId}` + ); + + this.gameLauncherWindow.on("closed", () => { + this.gameLauncherWindow = null; + }); + + if (!app.isPackaged || isStaging) { + this.gameLauncherWindow.webContents.openDevTools(); + } + } + + public static showGameLauncherWindow() { + if (this.gameLauncherWindow && !this.gameLauncherWindow.isDestroyed()) { + this.gameLauncherWindow.show(); + } + } + + public static closeGameLauncherWindow() { + if (this.gameLauncherWindow) { + this.gameLauncherWindow.close(); + this.gameLauncherWindow = null; + } + } + + public static openMainWindow() { + if (this.mainWindow) { + this.mainWindow.show(); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + } else { + this.createMainWindow(); + } + } + public static redirect(hash: string) { if (!this.mainWindow) this.createMainWindow(); this.loadMainWindowURL(hash); diff --git a/src/preload/index.ts b/src/preload/index.ts index 6d929d99..6da68bf0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -675,6 +675,11 @@ contextBridge.exposeInMainWorld("electron", { closeEditorWindow: (themeId?: string) => ipcRenderer.invoke("closeEditorWindow", themeId), + /* Game Launcher Window */ + showGameLauncherWindow: () => ipcRenderer.invoke("showGameLauncherWindow"), + closeGameLauncherWindow: () => ipcRenderer.invoke("closeGameLauncherWindow"), + openMainWindow: () => ipcRenderer.invoke("openMainWindow"), + /* LevelDB Generic CRUD */ leveldb: { get: ( diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e86c207b..5b9016e8 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -462,6 +462,11 @@ declare global { onCustomThemeUpdated: (cb: () => void) => () => Electron.IpcRenderer; closeEditorWindow: (themeId?: string) => Promise; + /* Game Launcher Window */ + showGameLauncherWindow: () => Promise; + closeGameLauncherWindow: () => Promise; + openMainWindow: () => Promise; + /* Download Options */ onNewDownloadOptions: ( cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index cc614b98..50d9983e 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -34,6 +34,7 @@ import ThemeEditor from "./pages/theme-editor/theme-editor"; import Library from "./pages/library/library"; import Notifications from "./pages/notifications/notifications"; import { AchievementNotification } from "./pages/achievements/notification/achievement-notification"; +import GameLauncher from "./pages/game-launcher/game-launcher"; console.log = logger.log; @@ -98,6 +99,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( path="/achievement-notification" element={} /> + } /> diff --git a/src/renderer/src/pages/game-launcher/game-launcher.scss b/src/renderer/src/pages/game-launcher/game-launcher.scss new file mode 100644 index 00000000..ec4c439c --- /dev/null +++ b/src/renderer/src/pages/game-launcher/game-launcher.scss @@ -0,0 +1,180 @@ +@use "../../scss/globals.scss"; + +.game-launcher { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + background: linear-gradient(135deg, #0d0d0d 0%, #1a1a2e 50%, #16213e 100%); + -webkit-app-region: drag; + padding: calc(globals.$spacing-unit * 3); + box-sizing: border-box; + position: relative; + overflow: hidden; + + &__glow { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient( + ellipse at top right, + rgba(22, 177, 149, 0.15) 0%, + transparent 50% + ); + pointer-events: none; + z-index: 0; + } + + &__logo-badge { + position: absolute; + top: calc(globals.$spacing-unit * 2); + right: calc(globals.$spacing-unit * 2); + z-index: 3; + + svg { + width: 28px; + height: 28px; + fill: globals.$body-color; + opacity: 0.6; + } + } + + &__content { + display: flex; + flex: 1; + gap: calc(globals.$spacing-unit * 2); + -webkit-app-region: no-drag; + min-height: 0; + position: relative; + z-index: 1; + } + + &__cover { + height: 100%; + width: auto; + max-width: 180px; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; + background-color: globals.$dark-background-color; + } + + &__cover-placeholder { + height: 100%; + width: 180px; + border-radius: 8px; + background-color: globals.$dark-background-color; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: globals.$muted-color; + } + + &__info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + + &__center { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + gap: calc(globals.$spacing-unit / 2); + } + + &__title { + font-size: 22px; + font-weight: 700; + color: globals.$body-color; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + padding-right: 40px; + } + + &__status { + font-size: 14px; + color: globals.$muted-color; + margin: 0; + display: flex; + align-items: center; + } + + &__dots { + display: inline-block; + width: 20px; + + &::after { + content: ""; + animation: dots 1.5s steps(4, end) infinite; + } + } + + @keyframes dots { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + 100% { + content: ""; + } + } + + &__stats { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + padding-top: calc(globals.$spacing-unit * 2); + } + + &__stat { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + } + + &__button { + width: 100%; + background-color: globals.$muted-color; + border: solid 1px transparent; + color: #0d0d0d; + border-radius: 8px; + padding: globals.$spacing-unit calc(globals.$spacing-unit * 2); + min-height: 40px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all ease 0.2s; + font-family: inherit; + display: flex; + align-items: center; + justify-content: center; + margin-top: calc(globals.$spacing-unit); + + &:hover { + background-color: #dadbe1; + } + + &:active { + opacity: globals.$active-opacity; + } + } +} diff --git a/src/renderer/src/pages/game-launcher/game-launcher.tsx b/src/renderer/src/pages/game-launcher/game-launcher.tsx new file mode 100644 index 00000000..b6ce2ffd --- /dev/null +++ b/src/renderer/src/pages/game-launcher/game-launcher.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ImageIcon, ClockIcon, TrophyIcon } from "@primer/octicons-react"; +import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; +import { darkenColor } from "@renderer/helpers"; +import { logger } from "@renderer/logger"; +import { average } from "color.js"; +import type { Game, GameShop, ShopAssets } from "@types"; +import "./game-launcher.scss"; + +export default function GameLauncher() { + const { t } = useTranslation("game_launcher"); + const [searchParams] = useSearchParams(); + + const shop = searchParams.get("shop") as GameShop; + const objectId = searchParams.get("objectId"); + + const [game, setGame] = useState(null); + const [gameAssets, setGameAssets] = useState(null); + const [imageError, setImageError] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + const [accentColor, setAccentColor] = useState(null); + const [colorExtracted, setColorExtracted] = useState(false); + const [colorError, setColorError] = useState(false); + const [windowShown, setWindowShown] = useState(false); + + const formatPlayTime = useCallback( + (playTimeInMilliseconds = 0) => { + const minutes = playTimeInMilliseconds / 60000; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes_short", { amount: minutes.toFixed(0) }); + } + + const hours = minutes / 60; + return t("amount_hours_short", { amount: hours.toFixed(1) }); + }, + [t] + ); + + useEffect(() => { + if (shop && objectId) { + window.electron.getGameByObjectId(shop, objectId).then((gameData) => { + setGame(gameData); + }); + + window.electron.getGameAssets(objectId, shop).then((assets) => { + setGameAssets(assets); + }); + } + }, [shop, objectId]); + + // Fallback auto-close timer - game detection will usually close it sooner + useEffect(() => { + if (!windowShown) return; + + const timer = setTimeout(() => { + window.electron.closeGameLauncherWindow(); + }, 5000); + + return () => clearTimeout(timer); + }, [windowShown]); + + const handleOpenHydra = () => { + window.electron.openMainWindow(); + window.electron.closeGameLauncherWindow(); + }; + + const coverImage = + gameAssets?.coverImageUrl?.replaceAll("\\", "/") || + game?.iconUrl?.replaceAll("\\", "/") || + game?.libraryHeroImageUrl?.replaceAll("\\", "/") || + ""; + const gameTitle = game?.title ?? gameAssets?.title ?? ""; + const playTime = game?.playTimeInMilliseconds ?? 0; + const achievementCount = game?.achievementCount ?? 0; + const unlockedAchievements = game?.unlockedAchievementCount ?? 0; + + const extractAccentColor = useCallback(async (imageUrl: string) => { + try { + const color = await average(imageUrl, { amount: 1, format: "hex" }); + const colorString = typeof color === "string" ? color : color.toString(); + setAccentColor(colorString); + } catch (error) { + logger.error("Failed to extract accent color:", error); + setColorError(true); + } finally { + setColorExtracted(true); + } + }, []); + + // Extract color as soon as we have the image URL + useEffect(() => { + if (coverImage && !colorExtracted) { + extractAccentColor(coverImage); + } + }, [coverImage, colorExtracted, extractAccentColor]); + + // Only show content when both image is loaded and color is extracted successfully + const isReady = imageLoaded && colorExtracted && !colorError; + const hasFailed = + imageError || colorError || (!coverImage && gameAssets !== null); + + // Show or close window based on loading state + useEffect(() => { + if (windowShown) return; + + if (hasFailed) { + window.electron.closeGameLauncherWindow(); + return; + } + + if (isReady) { + window.electron.showGameLauncherWindow(); + setWindowShown(true); + } + }, [isReady, hasFailed, windowShown]); + + const backgroundStyle = accentColor + ? { + background: `linear-gradient(135deg, ${darkenColor(accentColor, 0.7)} 0%, ${darkenColor(accentColor, 0.8, 0.9)} 50%, ${darkenColor(accentColor, 0.85, 0.8)} 100%)`, + } + : undefined; + + const glowStyle = accentColor + ? { + background: `radial-gradient(ellipse at top right, ${darkenColor(accentColor, 0.3, 0.15)} 0%, transparent 50%)`, + } + : undefined; + + return ( +
+
+ +
+ +
+ +
+ {imageError || !coverImage ? ( +
+ +
+ ) : ( + <> + {!isReady && ( +
+ +
+ )} + {gameTitle} setImageLoaded(true)} + onError={() => setImageError(true)} + /> + + )} + +
+
+

{gameTitle}

+ +

+ {t("launching_base")} + +

+ + +
+ + {(playTime > 0 || achievementCount > 0) && ( +
+ {playTime > 0 && ( + + + {formatPlayTime(playTime)} + + )} + + {achievementCount > 0 && ( + + + {unlockedAchievements}/{achievementCount} + + )} +
+ )} +
+
+
+ ); +}