From 9e4b2a02c15a0e780aa32bc9b03b0c2e3a93253f Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Thu, 26 Feb 2026 00:28:14 +0200 Subject: [PATCH] feat: Made design more responsive fix: Made blurring server side to help with performance fix: Fixed shortcut useEffect loop --- .vscode/settings.json | 5 +- .vscode/tasks.json | 4 + bun.lock | 55 +++++++++ package.json | 1 + scripts/dev.ts | 7 +- src/bun/api/games/games.ts | 110 +++++++++++------- src/bun/api/games/platforms.ts | 2 +- src/bun/api/games/services/utils.ts | 8 +- src/bun/api/schema/app.ts | 21 +++- src/bun/utils/browser-params.ts | 2 +- .../components/AnimatedBackground.tsx | 58 +++++---- src/mainview/components/CardList.tsx | 5 +- src/mainview/components/CollectionList.tsx | 2 +- src/mainview/components/CollectionsDetail.tsx | 4 +- src/mainview/components/ContextDialog.tsx | 8 +- src/mainview/components/FilePicker.tsx | 16 +-- src/mainview/components/Filters.tsx | 9 +- src/mainview/components/GameCard.tsx | 12 +- src/mainview/components/GameList.tsx | 34 ++++-- src/mainview/components/Header.tsx | 99 ++++++++-------- src/mainview/components/PlatformsList.tsx | 95 ++++++++------- src/mainview/components/ShortcutPrompt.tsx | 6 +- src/mainview/components/Shortcuts.tsx | 2 +- .../components/options/OptionSpace.tsx | 6 +- src/mainview/index.css | 81 +++++++++++-- src/mainview/index.html | 4 + src/mainview/routes/__root.tsx | 5 +- src/mainview/routes/game/$source.$id.tsx | 107 ++++++++--------- src/mainview/routes/index.tsx | 74 +++++++----- src/mainview/routes/platform.$source.$id.tsx | 2 +- src/mainview/routes/settings/accounts.tsx | 10 +- src/mainview/routes/settings/directories.tsx | 10 +- src/mainview/routes/settings/route.tsx | 22 ++-- src/mainview/scripts/queries.ts | 13 ++- src/mainview/scripts/shortcuts.ts | 1 + src/mainview/scripts/spatialNavigation.ts | 2 +- src/mainview/scripts/utils.ts | 8 +- src/shared/constants.ts | 2 +- 38 files changed, 583 insertions(+), 329 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d7ae22e..f5ad09a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,5 +35,8 @@ "WAYLAND_DISPLAY": "wayland-0", "XDG_RUNTIME_DIR": "/run/user/1000", "GPG_TTY": "/dev/tty" - } + }, + "editor.suggest.preview": true, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "editor.wordWrap": "on" } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index aaceda4..f9c73b7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -39,6 +39,10 @@ "type": "shell", "command": "bun run dev:hmr", "isBackground": true, + "options": { + "env": { + } + }, "problemMatcher": [], "presentation": { "echo": true, diff --git a/bun.lock b/bun.lock index a26044e..a9b824c 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "sharp": "^0.34.5", "systeminformation": "^5.31.1", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", @@ -136,6 +137,8 @@ "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -204,6 +207,56 @@ "@hey-api/types": ["@hey-api/types@0.1.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-mZaiPOWH761yD4GjDQvtjS2ZYLu5o5pI1TVSvV/u7cmbybv51/FVtinFBeaE1kFQCKZ8OQpn2ezjLBJrKsGATw=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], @@ -896,6 +949,8 @@ "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/package.json b/package.json index 287beb6..166be2e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "sharp": "^0.34.5", "systeminformation": "^5.31.1", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", diff --git a/scripts/dev.ts b/scripts/dev.ts index 6279dd5..a622cf8 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -9,8 +9,11 @@ function spawnServer () return Bun.spawn(["bun", "run", "--inspect=127.0.0.1:9229/fixed-session", '--watch', "./src/bun/index.ts"], { env: { ...Bun.env, - HEADLESS: "true" + HEADLESS: "true", }, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", ipc (message, subprocess, handle) { if (message.type === 'exitapp') @@ -25,7 +28,7 @@ function spawnBrowser () { try { - return browser(events, false); + return browser(events, !!Bun.env.FORCE_BROWSER); } catch (error) { console.error(error); diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index b4df373..e79dd19 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,11 +1,11 @@ import Elysia, { status } from "elysia"; -import { activeGame, config, db, events, setActiveGame, taskQueue } from "../app"; -import { and, eq, getTableColumns } from "drizzle-orm"; +import { config, db, taskQueue } from "../app"; +import { and, eq, getTableColumns, sql } from "drizzle-orm"; import z from "zod"; import * as schema from "../schema/app"; import fs from "node:fs/promises"; import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants"; -import { getRomApiRomsIdGet, getRomsApiRomsGet, updateRomUserApiRomsIdPropsPut } from "@clients/romm"; +import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils"; @@ -13,9 +13,10 @@ import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/s import { errorToResponse } from "elysia/adapter/bun/handler"; import { launchCommand } from "./services/launchGameService"; import { getErrorMessage } from "@/bun/utils"; +import sharp from 'sharp'; export default new Elysia() - .get('/game/local/:id/cover', async ({ params: { id }, set }) => + .get('/game/local/:id/cover', async ({ params: { id }, query: { blur, width, height }, set }) => { const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) }); if (!coverBlob || !coverBlob.cover) @@ -26,9 +27,23 @@ export default new Elysia() { set.headers["content-type"] = coverBlob.cover_type; } - return status(200, coverBlob.cover); - }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) - .get('/screenshot/:id', async ({ params: { id }, set }) => + + return sharp(coverBlob.cover).resize({ width, height, withoutEnlargement: true }).blur(blur); + }, { + params: z.object({ id: z.coerce.number() }), + query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) + }) + .get('/image/:source/*', async ({ params: { source, "*": path }, query: { blur, width, height } }) => + { + if (source === 'romm') + { + const rommAdress = config.get('rommAddress'); + const rommFetch = await fetch(`${rommAdress}/${path}`); + return sharp(await rommFetch.arrayBuffer()).resize({ width, height, withoutEnlargement: true }).sharpen().blur(blur); + } + return status('Not Found'); + }, { query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) }) + .get('/screenshot/:id', async ({ params: { id }, query: { blur, width, height }, set }) => { const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } }); if (screenshot) @@ -37,12 +52,15 @@ export default new Elysia() { set.headers["content-type"] = screenshot.type; } - return screenshot.content; + return sharp(screenshot.content).resize({ width, height, withoutEnlargement: true }).blur(blur); } return status(404); - }, { params: z.object({ id: z.coerce.number() }) }) + }, { + params: z.object({ id: z.coerce.number() }), + query: z.object({ blur: z.coerce.number().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional() }) + }) .get("/game/local/:id/installed", async ({ params: { id } }) => { const data = await db.query.games.findFirst({ where: eq(schema.games.id, id) }); @@ -69,32 +87,34 @@ export default new Elysia() if (!collection_id) { const localGames = await db.select({ - platform_display_name: schema.platforms.name, - id: schema.games.id, - last_played: schema.games.last_played, - created_at: schema.games.created_at, - platform_id: schema.games.platform_id, - slug: schema.games.slug, - name: schema.games.name, - path_fs: schema.games.path_fs, - source_id: schema.games.source_id, - source: schema.games.source - }).from(schema.games) - .leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)) + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .where(and(...where)); localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!)); games.push(...localGames.map(g => { const game: FrontEndGameType = { - ...g, - platform_display_name: g.platform_display_name ?? "Local", + platform_display_name: g.platform?.name ?? "Local", id: { id: g.id, source: 'local' }, updated_at: g.created_at, path_cover: `/api/romm/game/local/${g.id}/cover`, source_id: g.source_id, source: g.source, - path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover` + path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, + paths_screenshots: g.screenshotIds?.map(s => `/api/romm/screenshot/${s}`) ?? [], + path_fs: g.path_fs, + last_played: g.last_played, + slug: g.slug, + name: g.name, + platform_id: g.platform_id }; return game; })); @@ -118,25 +138,35 @@ export default new Elysia() { async function getLocalGameDetailed (match: any) { - const localGames = await db.select({ - platform_display_name: schema.platforms.name, - ...getTableColumns(schema.games) - }).from(schema.games).where(match).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)); - if (localGames.length > 0) + const localGame = await db.query.games.findFirst({ + where: match, + with: { + screenshots: { columns: { id: true } }, + platform: { columns: { name: true } } + } + }); + if (localGame) { - const screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, localGames[0].id), columns: { id: true } }); - const exists = await checkInstalled(localGames[0].path_fs); - const fileSize = await calculateSize(localGames[0].path_fs); + const exists = await checkInstalled(localGame.path_fs); + const fileSize = await calculateSize(localGame.path_fs); const game: FrontEndGameTypeDetailed = { - ...localGames[0], - path_cover: `/api/romm/game/local/${localGames[0].id}/cover`, - updated_at: localGames[0].created_at, - id: { id: localGames[0].id, source: 'local' }, - path_platform_cover: `/api/romm/platform/local/${localGames[0].platform_id}/cover`, + path_cover: `/api/romm/game/local/${localGame.id}/cover`, + updated_at: localGame.created_at, + id: { id: localGame.id, source: 'local' }, + path_platform_cover: `/api/romm/platform/local/${localGame.platform_id}/cover`, fs_size_bytes: fileSize ?? null, - paths_screenshots: screenshots.map(s => `/api/romm/screenshot/${s.id}`), + paths_screenshots: localGame.screenshots.map(s => `/api/romm/screenshot/${s.id}`), local: true, - missing: !exists + missing: !exists, + platform_display_name: localGame.platform.name, + summary: localGame.summary, + source: localGame.source, + source_id: localGame.source_id, + path_fs: localGame.path_fs, + last_played: localGame.last_played, + slug: localGame.slug, + name: localGame.name, + platform_id: localGame.platform_id }; return game; } @@ -146,14 +176,12 @@ export default new Elysia() if (source === 'local') { - const localGame = await getLocalGameDetailed(eq(schema.games.id, id)); if (localGame) return localGame; return status('Not Found'); } else { - const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); if (localGame) return localGame; diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index eed4b78..31a73a2 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -28,7 +28,7 @@ export default new Elysia() slug: p.slug, name: p.display_name, family_name: p.family_name, - path_cover: `/api/romm/assets/platforms/${p.slug}.svg`, + path_cover: `/api/romm/image/romm/assets/platforms/${p.slug}.svg`, game_count: p.rom_count, updated_at: new Date(p.updated_at), id: { source: 'romm', id: p.id }, diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 7f682aa..573e6b7 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -28,7 +28,7 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType { const game: FrontEndGameType = { id: { id: rom.id, source: 'romm' }, - path_cover: `/api/romm${rom.path_cover_large}`, + path_cover: `/api/romm/image/romm${rom.path_cover_large}`, last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.updated_at), slug: rom.slug, @@ -36,9 +36,10 @@ export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType platform_display_name: rom.platform_display_name, name: rom.name, path_fs: null, - path_platform_cover: `/api/romm/assets/platforms/${rom.platform_slug}.svg`, + path_platform_cover: `/api/romm/image/romm/assets/platforms/${rom.platform_slug}.svg`, source: null, - source_id: null + source_id: null, + paths_screenshots: rom.merged_screenshots.map(s => `/api/romm/image/romm/${s}`), }; return game; @@ -50,7 +51,6 @@ export function convertRomToFrontendDetailed (rom: DetailedRomSchema) ...convertRomToFrontend(rom), summary: rom.summary, fs_size_bytes: rom.fs_size_bytes, - paths_screenshots: rom.merged_screenshots.map(s => `/api/romm${s}`), local: false, missing: rom.missing_from_fs }; diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index d4e7315..a0263f2 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { sql, relations } from "drizzle-orm"; import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core"; export const games = sqliteTable('games', { @@ -19,6 +19,14 @@ export const games = sqliteTable('games', { summary: text("summary") }); +export const gamesRelations = relations(games, ({ many, one }) => ({ + screenshots: many(screenshots), + platform: one(platforms, { + fields: [games.id], + references: [platforms.id] + }) +})); + export const platforms = sqliteTable('platforms', { id: integer("id").primaryKey({ autoIncrement: true }), igdb_id: integer("igdb_id").unique(), @@ -35,6 +43,8 @@ export const platforms = sqliteTable('platforms', { family_name: text("family_name") }); +export const platformsRelations = relations(platforms, ({ many }) => ({ games: many(games) })); + export const collections_games = sqliteTable('collections_games', { collection_id: integer('collection_id').notNull().references(() => collections.id, { onDelete: 'cascade', onUpdate: 'cascade' }), game_id: integer('game_id').notNull().references(() => games.id, { onDelete: 'cascade', onUpdate: 'cascade' }), @@ -51,4 +61,11 @@ export const screenshots = sqliteTable('screenshots', { game_id: integer('game_id').references(() => games.id, { onDelete: 'cascade', onUpdate: 'cascade' }), content: blob('content', { mode: 'buffer' }).notNull(), type: text('type') -}); \ No newline at end of file +}); + +export const screenshotsRelations = relations(screenshots, ({ one }) => ({ + game: one(games, { + fields: [screenshots.game_id], + references: [games.id] + }) +})); \ No newline at end of file diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 698c104..c10286b 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -10,7 +10,7 @@ import { host } from "./host"; export async function BuildParams (data: { configPath: string; }) { const validBrowser = await getBrowserPath({ - browserOrder: ['chrome', 'chromium'] + browserOrder: Bun.env.BROWSER_PRIORITY ? Bun.env.BROWSER_PRIORITY.split(',') as any : ['chrome', 'chromium'] }); if (!validBrowser) diff --git a/src/mainview/components/AnimatedBackground.tsx b/src/mainview/components/AnimatedBackground.tsx index e1bbe9b..574a1bf 100644 --- a/src/mainview/components/AnimatedBackground.tsx +++ b/src/mainview/components/AnimatedBackground.tsx @@ -1,5 +1,6 @@ + import classNames from 'classnames'; -import React, { createContext, JSX, Ref, useContext, useEffect, useState } from 'react'; +import { createContext, JSX, Ref, useContext, useEffect, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { useSessionStorage } from 'usehooks-ts'; @@ -8,47 +9,48 @@ export const AnimatedBackgroundContext = createContext({} as { setBackground: (u export function AnimatedBackground (data: { children?: any; backgroundKey?: string; - backgroundUrl?: string; + backgroundUrl?: string | URL; ref?: Ref; className?: string; animated?: boolean, + scrolling?: boolean; }) { - const blurBackground = true; const animateBackground = true; - const [lastBackgroundUrl, setLastBackgroundUrl] = data.backgroundKey ? useSessionStorage( - `${data.backgroundKey!}-last`, - data.backgroundUrl, - ) : useState(); - const [backgroundUrl, setBackgroundUrl] = data.backgroundKey ? useSessionStorage( data.backgroundKey!, - data.backgroundUrl, + data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined, ) : useState(); useEffect(() => { - setBackgroundUrl(data.backgroundUrl); + setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined); }, [data.backgroundUrl]); + const finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined; + const blur = localStorage.getItem('background-blur') !== "false"; + if (blur) + { + if (!finalBackgroundUrl?.searchParams.has('blur')) + { + finalBackgroundUrl?.searchParams.set('blur', String(24)); + } + + finalBackgroundUrl?.searchParams.set('height', String(320)); + } + function handleSetBackground (url: string) { - setLastBackgroundUrl(backgroundUrl); setBackgroundUrl(url); } const bgColor = "bg-base-content"; - let backgroundStyle = (url: string) => `linear-gradient( - color-mix(in srgb, var(--color-base-300) 60%, transparent), - color-mix(in srgb, var(--color-base-100) 80%, transparent) - ), url('${url}') center / cover`; - let backgroundElements: JSX.Element | undefined = undefined; if (true) { - backgroundElements =
+ backgroundElements =
@@ -62,11 +64,25 @@ export function AnimatedBackground (data: { return (
- {!!lastBackgroundUrl &&
} - {!!backgroundUrl &&
} - {blurBackground &&
} + {!data.scrolling &&
+ { e.currentTarget.classList.add(blur ? "animate-bg-zoom-big" : "animate-bg-zoom")} + >} +
+
} {data.animated && animateBackground &&
{backgroundElements}
} diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 69beb6a..684eba7 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -74,8 +74,9 @@ export function CardList (data: { id={`card-list-${data.id}`} ref={ref} save-child-focus="session" - className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ", - data.grid ? "card-grid h-fit gap-5" : 'card-list md:gap-6 sm:gap-2', + className={twMerge("items-center justify-center-safe landscape:h-(--game-card-height) ", + data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-(--game-card-height) grid-cols-[repeat(auto-fill,var(--game-card-width))]" : + 'landscape:flex sm:gap-2 md:gap-6 portrait:grid portrait:auto-rows-(--game-card-height) portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))]', data.className )} onKeyDown={(e) => diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 5c0c88f..51d89d3 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -46,7 +46,7 @@ export default function CollectionList (data: { onGameFocus={(id, node, details) => { data.setBackground( - `https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`, + `https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`, ); data.onFocus?.(id, node, details); }} diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 5d76df8..a801d14 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -57,8 +57,8 @@ export function CollectionsDetail (data: CollectionsDetailParams)
}, { id: "filter", icon: }]} />
-
-
+
+
{data.title} void; class return
  • + twMerge("flex cursor-pointer sm:text-sm md:text-base")}> -
    @@ -115,11 +115,11 @@ export function ContextDialog (data: {
    e.stopPropagation()} > {data.children} diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index c7aac7d..07a0805 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -45,7 +45,7 @@ function List (data: { action: handleReturn, id: `${data.id}...`, type: 'primary', - content:
    ...
    , + content:
    ...
    , icon: , shortcuts: [{ label: "Up", action: handleReturn, button: GamePadButtonCode.A }] }, @@ -149,10 +149,10 @@ function OptionButtons (data: { }) { const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect }); - return
    + return
    {data.showConfirm && } - +
    ; } @@ -161,7 +161,7 @@ function DriveElement (data: { id: string, isActive: boolean, label: string; onS { const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect }); return
  • + return
      {drives?.filter(d => d.mountPoint) .sort((a, b) => b.mountPoint!.length - a.mountPoint!.length) @@ -208,7 +208,7 @@ function ListWithDrives (data: { focusKey: `main-${data.id}`, preferredChildFocusKey: `list-${data.id}` }); - return
      + return
      setCurrentPath(p)} id={`drives-${data.id}`} /> @@ -264,7 +264,7 @@ export default function FilePicker (data: { activeDrive }}> {!!fullPath && -
      +
      - {(filesLoading || drivesLoading) && } + {(filesLoading || drivesLoading) && }
      } -
        +
        • - +
        • {Object.entries(data.options)?.map(([id, option]) => ( ))}
        • - +
        diff --git a/src/mainview/components/GameCard.tsx b/src/mainview/components/GameCard.tsx index 14f2751..d6b09b6 100644 --- a/src/mainview/components/GameCard.tsx +++ b/src/mainview/components/GameCard.tsx @@ -69,7 +69,7 @@ export default function GameCard (data: GameCardParams) "overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer", classNames({ "focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer, - "group hover:focused hover:animate-wiggle hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isPointer, + "group hover:focused hover:animate-wiggle sm:hover:ring-4 md:hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isPointer, "h-(--game-card-height)": typeof data.preview === "string" }), data.className @@ -77,20 +77,20 @@ export default function GameCard (data: GameCardParams) >
        {typeof data.preview === "string" ? ( - + ) : ( typeof data.preview === 'function' ? data.preview({ focused }) : data.preview )}
        -
        +
        {data.badges?.map((b, i) =>
        ) }
        -
        +
        {data.title}
        diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 14e16a3..806d8e3 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; @@ -7,6 +7,7 @@ import { rommApi } from "../scripts/clientApi"; import { HardDrive } from "lucide-react"; import { JSX } from "react"; import { GameCardFocusHandler } from "./GameCard"; +import { gameQuery } from "../scripts/queries"; export interface GameListParams { @@ -28,15 +29,25 @@ export function GameList (data: GameListParams) }).then(d => d.data) }); const navigator = useNavigate(); + const queryClient = useQueryClient(); - const handleFocus = (id: FrontEndId) => + const handleFocus = (id: FrontEndId, source: string | null, sourceId: number | null) => { const game = games.data?.games.find((g) => g.id === id); if (game) { - data.setBackground?.( - `${RPC_URL(__HOST__)}${game.path_cover}`, - ); + try + { + const screenshotUrl = new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`); + const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`); + const previewUrl = localStorage.getItem('background-blur') !== "false" ? coverUrl : screenshotUrl; + previewUrl.searchParams.delete('ts'); + data.setBackground?.(previewUrl.href); + queryClient.prefetchQuery(gameQuery(source ?? id.source, sourceId ?? id.id)); + } catch + { + + } } }; @@ -61,8 +72,13 @@ export function GameList (data: GameListParams) const badges: JSX.Element[] = []; if (g.id.source === 'local') { - badges.push(); + badges.push(); } + const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); + previewUrl.searchParams.delete('ts'); + previewUrl.searchParams.set('width', "640"); + const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); return { id: `game-${g.id.source}-${g.id.id}`, @@ -70,14 +86,14 @@ export function GameList (data: GameListParams) title: g.name ?? "", subtitle: (
        - {!!g.path_platform_cover && } + {!!g.path_platform_cover && }

        {g.platform_display_name}

        ), - previewUrl: `${RPC_URL(__HOST__)}${g.path_cover}`, + previewUrl: previewUrl.href, badges: badges, onSelect: () => data.onGameSelect ? data.onGameSelect(g.id) : handleDefaultSelect(g.id, g.source, g.source_id), - onFocus: () => handleFocus(g.id) + onFocus: () => handleFocus(g.id, g.source, g.source_id) } satisfies GameMetaExtra; }, ) ?? []} diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index e021551..928fb04 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -25,10 +25,9 @@ import { useQuery } from "@tanstack/react-query"; import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen"; import { RPC_URL } from "../../shared/constants"; import { JSX, useEffect, useRef } from "react"; -import { useNavigate } from "@tanstack/react-router"; import { SaveSource } from "../scripts/spatialNavigation"; import { systemApi } from "../scripts/clientApi"; -import { twMerge } from "tailwind-merge"; +import { Router } from ".."; function HeaderAvatar (data: { id: string; @@ -56,13 +55,13 @@ function HeaderAvatar (data: { ref={ref} onClick={data.onSelect} className={classNames( - `avatar indicator ring-base-100 ring-offset-base-100 size-14 rounded-full flex items-center justify-center`, + `avatar indicator ring-base-100 ring-offset-base-100 sm:size-8 md:size-14 rounded-full flex items-center justify-center`, bgColors[data.type ?? "none"], "text-base-content cursor-pointer transition-all drop-shadow-md", "hover:ring-primary hover:ring-7", { "ring-5 hover:ring-offset-5": data.active, - "ring-7 ring-primary ring-offset-base-100": focused, + "sm:ring-4 md:ring-7 ring-primary ring-offset-base-100": focused, "ring-offset-5": focused && data.active, }, data.className, @@ -85,7 +84,7 @@ function HeaderAvatar (data: { ) : ( )} - +
        ); @@ -113,7 +112,7 @@ function NotificationStatus () { const hasUnread = false; return
        - +
        ; } @@ -150,7 +149,7 @@ function ClockStatus () return () => clearTimeout(timeout); }, []); - return
        ; + return
        ; } function BluetoothStatus () @@ -227,10 +226,8 @@ function BatteryStatus ()
        ; } -export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; }) +export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { - const { ref, focusKey } = useFocusable({ focusKey: "header-elements" }); - const navigate = useNavigate(); const rommOnline = useQuery({ ...statsApiStatsGetOptions(), refetchInterval: 30000, @@ -250,7 +247,6 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc { indicator = "status-success"; } - const accounts: HeaderAccount[] = [{ id: 'romm', previewUrl: [ `${RPC_URL(__HOST__)}/api/romm/assets/logos/romm_logo_xbox_one_square.svg`, @@ -258,52 +254,61 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc action: () => { SaveSource('settings'); - navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } }); + Router.navigate({ to: '/settings/accounts', viewTransition: { types: ['zoom-in'] }, search: { focus: 'rommAddress' } }); }, status: user.data ? "status-success" : 'status-error', type: 'secondary' }, ...data.accounts ?? []]; + return
        + {accounts?.map(a => )} +
        ; +} + +export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) +{ + return
        +
        + + + + + +
        + {!!data.buttons &&
        } +
        + {data.buttonElements ?? data.buttons?.map(b => )} +
        +
        ; +} + +export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; }) +{ + const { ref, focusKey } = useFocusable({ focusKey: "header-elements" }); return (
        -
        - {accounts?.map(a => )} - {data.title} -
        -
        -
        - - - - - -
        - {!!data.buttons &&
        } -
        - {data.buttonElements ?? data.buttons?.map(b => )} -
        -
        + + {data.title} +
        ); diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 607a4e2..8b9c15a 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -5,11 +5,11 @@ import { CardList, GameMetaExtra } from "./CardList"; import classNames from "classnames"; import { rommApi } from "../scripts/clientApi"; import { SaveSource } from "../scripts/spatialNavigation"; -import { JSX } from "react"; +import { JSX, useMemo } from "react"; import { HardDrive } from "lucide-react"; import { GameCardFocusHandler } from "./GameCard"; -export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; }) +export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; grid?: boolean; }) { const navigate = useNavigate(); const { data: platforms } = useSuspenseQuery( @@ -25,55 +25,60 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) staleTime: DefaultRommStaleTime, }); + const platformsMapped = useMemo(() => platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime()) + .map((g, i) => + { + const badges: JSX.Element[] = []; + badges.push({g.game_count}); + if (g.hasLocal) + badges.push(); + const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); + coverUrl.searchParams.set('width', "320"); + const entry: GameMetaExtra = { + id: g.slug, + focusKey: g.slug, + title: g.name, + subtitle: g.family_name ?? "", + previewUrl: "", + badges, + onFocus: () => data.setBackground( + `https://picsum.photos/id/${10 + i}/100/100.webp?blur=10`, + ), + onSelect: () => + { + SaveSource('game-list'); + navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } }); + }, + preview: + ({ focused }) =>
        + +
        + , + }; + return entry; + }), [platforms]); + return ( a.updated_at.getTime() - b.updated_at.getTime()) - .map((g) => - { - const badges: JSX.Element[] = []; - badges.push({g.game_count}); - if (g.hasLocal) - badges.push(); - const entry: GameMetaExtra = { - id: g.slug, - focusKey: g.slug, - title: g.name, - subtitle: g.family_name ?? "", - previewUrl: "", - badges, - onFocus: () => data.setBackground( - `https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`, - ), - onSelect: () => - { - SaveSource('game-list'); - navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } }); - }, - preview: - ({ focused }) =>
        - -
        - , - }; - return entry; - })} + games={platformsMapped} onSelectGame={(id) => { diff --git a/src/mainview/components/ShortcutPrompt.tsx b/src/mainview/components/ShortcutPrompt.tsx index 35889d8..9b29a50 100644 --- a/src/mainview/components/ShortcutPrompt.tsx +++ b/src/mainview/components/ShortcutPrompt.tsx @@ -15,17 +15,15 @@ export default function ShortcutPrompt (data: {
        - {data.icon && } + {data.icon && } {data.label}
        ); diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index 8e15f77..dbea853 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -48,7 +48,7 @@ export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) const { control } = useActiveControl(); const showKeyboard = control === 'keyboard' || control === 'mouse'; return ( -
        +
        {data.shortcuts?.filter(s => !!s.label).map((s, i) =>
      • - {data.children} +
        + {data.children} +
      • diff --git a/src/mainview/index.css b/src/mainview/index.css index f30ada9..a039004 100644 --- a/src/mainview/index.css +++ b/src/mainview/index.css @@ -3,7 +3,8 @@ @plugin "daisyui"; @theme { - --breakpoint-xs: 20rem; + --breakpoint-sm: 0px; + --breakpoint-md: 1280px; --animate-wiggle: wiggle 0.3s ease-in-out 1; --animate-rotate: rotate 0.3s ease-in-out 1 0.2s; @@ -13,6 +14,33 @@ --animate-scale-delayed: scale 0.3s ease-in-out 1 100ms; --animate-scale-small: scale-small 0.3s ease-in-out 1; --animate-fade-out: fade-out 0.3s ease-out 1; + --animate-bg-zoom: zoom-in-scale 0.6s ease-out 1 forwards; + --animate-bg-zoom-big: zoom-in-scale-big 0.6s ease-out 1 forwards; + --animate-bg-zoom-scroll: zoom-in-bg 0.6s ease-out 1 forwards; + + @keyframes zoom-in-scale-big { + 0% { + scale: 150%; + opacity: 0; + } + + 100% { + scale: 100%; + opacity: 1; + } + } + + @keyframes zoom-in-scale { + 0% { + scale: 105%; + opacity: 0; + } + + 100% { + scale: 100%; + opacity: 1; + } + } @keyframes slide-up { 0% { @@ -109,16 +137,44 @@ @layer base { @variant sm { :root { - --game-card-height: calc(var(--spacing) * 55); - --game-card-width: calc(var(--spacing) * 35.2); + --game-card-height: calc(var(--spacing) * 52); + --game-card-height-safe: calc(var(--spacing) * 52); + --game-card-width: calc(var(--spacing) * 33.28); + } + } + + @variant landscape { + @keyframes zoom-in-bg { + 0% { + background-size: 125% auto; + background-color: var(--color-base-100); + } + + 100% { + background-size: 120% auto; + background-color: var(--color-base-300); + } + } + } + + @variant portrait { + @keyframes zoom-in-bg { + 0% { + background-size: auto 125%; + background-color: var(--color-base-100); + } + + 100% { + background-size: auto 120%; + background-color: var(--color-base-300); + } } } -} -@layer base { @variant md { :root { --game-card-height: calc(var(--spacing) * 100); + --game-card-height-safe: calc(var(--spacing) * 100 + 3.5rem); --game-card-width: calc(var(--spacing) * 64); } } @@ -134,6 +190,10 @@ html { text-rendering: optimizeLegibility; } +body { + font-family: 'Alan Sans', sans-serif; +} + @layer components { .background { @@ -160,8 +220,7 @@ html { } .header-icon svg { - @apply md:w-8 md:h-8 md:min-w-8 md:min-h-8; - @apply sm:w-5 sm:h-5 sm:min-w-5 sm:min-h-5; + @apply md:w-8 md:h-8 md:min-w-8 md:min-h-8 sm:w-5 sm:h-5 sm:min-w-5 sm:min-h-5; } .header-icon-small svg { @@ -172,11 +231,15 @@ html { display: flex; } - .card-grid { + .test { display: grid; grid-template-columns: repeat(auto-fill, var(--game-card-width)); grid-auto-rows: var(--game-card-height); - padding-bottom: 16px; + } + + .card-grid { + + @apply grid pb-4; } .no-scrollbar { diff --git a/src/mainview/index.html b/src/mainview/index.html index 849550e..6332b4d 100644 --- a/src/mainview/index.html +++ b/src/mainview/index.html @@ -4,6 +4,10 @@ + GameFlow diff --git a/src/mainview/routes/__root.tsx b/src/mainview/routes/__root.tsx index e6ac8f0..943c280 100644 --- a/src/mainview/routes/__root.tsx +++ b/src/mainview/routes/__root.tsx @@ -4,6 +4,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { RouterContext } from ".."; import Notifications from "../components/Notifications"; import { Toaster } from "react-hot-toast"; +import { mobileCheck } from "../scripts/utils"; export const Route = createRootRouteWithContext()({ component: RootComponent, @@ -11,12 +12,14 @@ export const Route = createRootRouteWithContext()({ function RootComponent () { + const isMobile = mobileCheck(); + return (
        - {import.meta.env.DEV && + {import.meta.env.DEV && !isMobile && <> diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 90fdd45..a8b275f 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -4,29 +4,19 @@ import { twJoin, twMerge } from "tailwind-merge"; import { JSX, RefObject, useEffect, useRef, useState } from "react"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { Clock, CloudDownload, Download, Folder, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react"; +import { Clock, CloudDownload, Download, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI } from "../../components/Header"; import prettyBytes from 'pretty-bytes'; -import { useEventListener } from "usehooks-ts"; import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { rommApi } from "../../scripts/clientApi"; import toast from "react-hot-toast"; -import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Router } from "../.."; import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; - -const gameQuery = (source: string, id: number) => queryOptions({ - queryKey: ['game', source, id], - queryFn: async () => - { - const { data, error } = await rommApi.api.romm.game({ source })({ id }).get(); - if (error) throw error; - return data; - } -}); +import { gameQuery } from "@/mainview/scripts/queries"; export const Route = createFileRoute("/game/$source/$id")({ loader: ({ params, context }) => @@ -66,7 +56,8 @@ function Details (data: { mainAreaRef: RefObject, game?: saveLastFocusedChild: false }); - const platformCoverImg = `${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`; + const platformCoverImg = new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover ?? ''}`); + platformCoverImg.searchParams.set("width", "64"); const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined; let fileSizeIcon: JSX.Element | undefined; @@ -84,17 +75,17 @@ function Details (data: { mainAreaRef: RefObject, game?: fileSizeIcon = ; } - return
        + return
        -
        -
        +
        +
        {gameCoverImg ? - : + :
        }
        -
        -
        +
        +
        } >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
        @@ -102,14 +93,15 @@ function Details (data: { mainAreaRef: RefObject, game?: {data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}
        } - } >{data.game?.platform_display_name ??
        }
        + } >{data.game?.platform_display_name ??
        }
        } > {data.game?.source ?? data.game?.id.source} {data.game?.local && local}
        -
        +
        +
        {data.game?.summary ??
        @@ -130,16 +122,18 @@ function Screenshot (data: { path: string; index: number; setFocused?: (index: n { const { ref, focused, focusSelf } = useFocusable({ focusKey: `screenshot-${data.index}`, - onFocus: () => + onFocus: (e, p, details) => { - (ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' }); data.setFocused?.(data.index); + (ref.current as HTMLElement).scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }); + + } }); 4096; - return ; + }))} onClick={e => focusSelf({ nativeEvent: e.nativeEvent })} ref={ref} src={`${RPC_URL(__HOST__)}${data.path}`} loading="lazy" />; } function Screenshots (data: { screenshots: string[]; }) @@ -148,24 +142,31 @@ function Screenshots (data: { screenshots: string[]; }) const [focusedScreenshot, setFocusedScreenshot] = useState(-1); const { ref, focusKey } = useFocusable({ focusKey: 'screenshot-list', - onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }), + onFocus: (e, p, details) => + { + if (!(details.nativeEvent instanceof TouchEvent)) + { + (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, onBlur: () => setFocusedScreenshot(-1) }); - return
        + return
        {data.screenshots.map((s, i) => )}
        {data.screenshots.map((s, i) => { const focused = i === focusedScreenshot; - return ; + return ; })}
        ; @@ -388,7 +389,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; }) error: 'bg-error text-error-content' }; - return
        + return
        @@ -402,7 +403,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; }) }}> - {!!hoverText &&

        {hoverText}

        } + {!!hoverText &&

        {hoverText}

        }
        ; } @@ -435,16 +436,16 @@ function ActionButton (data: { const styles = { primary: twMerge("bg-primary text-primary-content", classNames({ - "bg-base-content text-base-300 ring-7 ring-primary": focused + "bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused })), base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({ - "bg-base-content text-base-300 ring-7 ring-primary": focused + "bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused })), accent: twMerge("bg-primary text-primary-content ", classNames({ - "bg-base-content text-base-300 ring-7 ring-primary": focused + "bg-base-content text-base-300 sm:ring-4 md:ring-7 ring-primary": focused })), error: twMerge("bg-error text-error-content ", classNames({ - "bg-error text-error-content ring-7 ring-primary": focused + "bg-error text-error-content sm:ring-4 md:ring-7 ring-primary": focused })), }; return ( @@ -454,8 +455,8 @@ function ActionButton (data: { onClick={data.onAction} data-tooltip={data.tooltip} data-tooltip_type={data.tooltip_type} - className={twMerge("header-icon flex flex-col gap-2 px-5 py-4 rounded-3xl text-2xl justify-center items-center cursor-pointer disabled:opacity-30", - "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> + className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30", + "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> {data.icon} {data.children} @@ -467,7 +468,7 @@ export default function GameDetailsUI () const { source, id } = Route.useParams(); const { data, isSuccess } = useQuery(gameQuery(source, Number(id))); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" }); - const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined; + const backgroundImage = data?.path_cover ? new URL(`${RPC_URL(__HOST__)}${data?.path_cover}`) : undefined; const mainAreaRef = useRef(null); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); @@ -483,20 +484,22 @@ export default function GameDetailsUI () }, [isSuccess]); return ( - -
        + +
        -
        +
        -
        Screenshots
        - {!!data && } -
        -
        -
        - -
        +
        +
        Screenshots
        + {!!data && } +
        +
        +
        + +
        +
        diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index c1216ec..f15674d 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -25,7 +25,7 @@ import } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { useEventListener } from "usehooks-ts"; -import { HeaderUI } from "../components/Header"; +import { HeaderAccounts, HeaderStatusBar, HeaderUI } from "../components/Header"; import { FilterUI } from "../components/Filters"; import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground"; import { GameList } from "../components/GameList"; @@ -43,6 +43,7 @@ import z from "zod"; import { Router } from ".."; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; +import { mobileCheck } from "../scripts/utils"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -61,6 +62,22 @@ const filters = { }, }; +let screenLock: WakeLockSentinel | undefined = undefined; +async function handleFullscreen () +{ + if (document.fullscreenElement) + { + await document.exitFullscreen(); + if (screenLock) + screenLock.release(); + } else + { + await document.documentElement.requestFullscreen(); + screenLock = await navigator.wakeLock.request('screen'); + return screenLock; + } +} + function HomeListError (data: { focused: boolean; }) { const error = useErrorBoundary(); @@ -123,10 +140,10 @@ function HomeList (data: { return ( -
        -
        +
        }> }> {lists[data.selectedFilter]} @@ -152,9 +169,7 @@ function MainMenu (data: {})
          , action: handleFullscreen }); + headerButtons.push({ id: "search", icon: }, { id: "power-button", icon: , external: true, action: () => closeMutation.mutate() }); return ( - + -
          - , action: () => document.documentElement.requestFullscreen() }, - { id: "search", icon: }, - { id: "power-button", icon: , external: true, action: () => closeMutation.mutate() } - ]} /> +
          +
          -
          +
          -
          - -
          -
          - -
          -
          + +
          +
          + +
          +
          + +
          +
          -
          -
          + ); diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index dc72c37..a0bced8 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -12,7 +12,7 @@ export const Route = createFileRoute("/platform/$source/$id")({ function PlatformTitle (data: { platformSlug?: string, platformName?: string; }) { - return
          + return
          {!!data.platformSlug && } diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index 6f86fbd..d83ddad 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -46,17 +46,17 @@ function LoginControls (data: { hasPassword: boolean; }) c.client.invalidateQueries({ queryKey: ["romm", "auth"] }); } }); - return
          + return
          {user.isError &&
          } {user.isSuccess && <> -
          Logged In As: {user.data?.username}
          +

          Logged In As:

          {user.data?.username}
          } - {data.hasPassword && - } -
          ; diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 60fe967..8cb7050 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -37,7 +37,7 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r shortcuts.push({ label: "Move Downloads", button: GamePadButtonCode.A, action: handleAction }); } useShortcuts(focusKey, () => shortcuts, [shortcuts]); - const { isMouse } = useActiveControl(); + const { isPointer } = useActiveControl(); return
        • }
          - {valid && isMouse && } + {valid && isPointer && } ; } @@ -87,7 +87,7 @@ function RouteComponent ()
          Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : })
          -
            +
              {drives?.drives.filter(d => d.mountPoint).map(d => )}
            -
            +
            {drives?.configPath} -
            +
          diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index f3999cd..d3e130d 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -69,7 +69,7 @@ function MenuItem (data: { ? handleNonFocusSelect : undefined, }); - const { isMouse } = useActiveControl(); + const { isPointer } = useActiveControl(); return (
        • {data.icon} - {data.label} +
          {data.label}
        • @@ -110,7 +110,7 @@ function SettingsMenu (data: {}) return
            } /> -
            -
            -