├── .commitlintrc.json ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── custom-changelog │ ├── action-dist │ │ ├── commit.hbs │ │ ├── footer.hbs │ │ ├── header.hbs │ │ ├── index.cjs │ │ ├── index.js.map │ │ ├── sourcemap-register.cjs │ │ └── template.hbs │ └── action.yml │ ├── pre-release.yml │ └── release.yml ├── .gitignore ├── .husky └── commit-msg ├── .lintstagedrc.json ├── .vscode ├── config.sh ├── defsettings.json └── tasks.json ├── LICENSE ├── README.md ├── assets ├── dpad.svg ├── howto.webm └── spinner.svg ├── crowdin.yml ├── decky.pyi ├── defaults └── py_modules │ └── vdf │ ├── LICENSE │ └── __init__.py ├── docs ├── capsule.png ├── filters.png ├── gamecontextmenu.png ├── manage.png ├── menucog-dark.svg ├── menucog-light.svg ├── start-dark.svg ├── start-light.svg ├── store-dark.svg └── store-light.svg ├── dump-strings.js ├── main.py ├── package.json ├── plugin.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── src ├── components │ ├── AppGridFilterBar.tsx │ ├── Chevron.tsx │ ├── Chips │ │ ├── Chip.tsx │ │ └── index.tsx │ ├── DropdownMultiselect.tsx │ ├── FooterGlyph.tsx │ ├── Icons │ │ ├── BoopIcon.tsx │ │ ├── EshopIcon.tsx │ │ ├── FlashpointIcon.tsx │ │ ├── GogIcon.tsx │ │ └── MenuIcon.tsx │ ├── Markdown.tsx │ ├── Motd.tsx │ ├── ResultsStateBar.tsx │ ├── TabSorter.tsx │ ├── asset │ │ ├── Asset.tsx │ │ ├── LazyImage.tsx │ │ └── LibraryImage.tsx │ ├── plugin-pages │ │ ├── AssetTab.tsx │ │ ├── AssetTabs.tsx │ │ ├── ManageTab.tsx │ │ └── SGDBPage.tsx │ └── qam-contents │ │ ├── GuideVideoField.tsx │ │ ├── PanelSocialButton.tsx │ │ ├── QuickAccessSettings.tsx │ │ └── Toolbar.tsx ├── constants.ts ├── hooks │ ├── useAssetSearch.tsx │ ├── useSGDB.tsx │ └── useSettings.tsx ├── i18n │ ├── bg.json │ ├── cs.json │ ├── da.json │ ├── de.json │ ├── el.json │ ├── es-419.json │ ├── es.json │ ├── fi.json │ ├── fr.json │ ├── hu.json │ ├── id.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── nl.json │ ├── no.json │ ├── pl.json │ ├── pt-br.json │ ├── pt.json │ ├── ro.json │ ├── ru.json │ ├── strings.json │ ├── sv.json │ ├── th.json │ ├── tr.json │ ├── uk.json │ ├── vi.json │ ├── zh-cn.json │ └── zh-tw.json ├── index.tsx ├── modals │ ├── BeggingModal.tsx │ ├── DetailsModal.tsx │ ├── FiltersModal.tsx │ ├── GameSelectionModal.tsx │ ├── LogoPositionerModal.tsx │ ├── OfficialAssetsModal.tsx │ └── TaborderModal.tsx ├── patches │ ├── capsuleGlowPatch.tsx │ ├── contextMenuPatch.tsx │ ├── homePatch.tsx │ ├── patchUtils.ts │ └── squareLibraryPatch.tsx ├── static-classes.ts ├── styles │ ├── _mixins.scss │ ├── modals │ │ ├── _details.scss │ │ ├── _filters.scss │ │ ├── _gameselect.scss │ │ ├── _logo_position.scss │ │ └── _official_assets.scss │ └── style.scss ├── types.d.ts └── utils │ ├── compareFilterWithDefaults.ts │ ├── getAppDetails.ts │ ├── getAppOverview.ts │ ├── getCurrentSteamUserId.ts │ ├── getCustomLogoPosition.ts │ ├── i18n.ts │ ├── log.ts │ ├── openFilePicker.tsx │ ├── showQrModal.tsx │ ├── showRestartConfirm.tsx │ ├── steam-api-language-map.ts │ └── styleInjector.ts ├── thumb.png └── tsconfig.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dump-strings.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "settings": { 8 | "react": { 9 | "version": "16.14.0" 10 | }, 11 | "import/resolver": { 12 | "typescript": true 13 | } 14 | }, 15 | "extends": [ 16 | "eslint:recommended", 17 | "plugin:react/recommended", 18 | "plugin:react/jsx-runtime", 19 | "plugin:@typescript-eslint/recommended", 20 | "plugin:import/recommended", 21 | "plugin:import/typescript" 22 | ], 23 | "parser": "@typescript-eslint/parser", 24 | "parserOptions": { 25 | "ecmaFeatures": { 26 | "jsx": true 27 | }, 28 | "ecmaVersion": "latest", 29 | "sourceType": "module" 30 | }, 31 | "plugins": [ 32 | "react", 33 | "react-hooks", 34 | "@typescript-eslint" 35 | ], 36 | "rules": { 37 | "indent": [ 38 | "error", 39 | 2 40 | ], 41 | "linebreak-style": [ 42 | "error", 43 | "unix" 44 | ], 45 | "quotes": [ 46 | "error", 47 | "single" 48 | ], 49 | "semi": [ 50 | "error", 51 | "always" 52 | ], 53 | "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 1 }], 54 | "comma-style": ["error", "last"], 55 | "comma-dangle": ["error", { 56 | "arrays": "always-multiline", 57 | "objects": "always-multiline", 58 | "imports": "always-multiline", 59 | "exports": "always-multiline", 60 | "functions": "never" 61 | }], 62 | "object-curly-spacing": ["error", "always"], 63 | "object-curly-newline": ["error", { 64 | "ImportDeclaration": { "multiline": true, "minProperties": 6 } 65 | }], 66 | "quote-props": ["error", "as-needed"], 67 | "arrow-parens": ["error", "always"], 68 | "no-trailing-spaces": ["error"], 69 | "jsx-quotes": ["error", "prefer-double"], 70 | "no-multi-spaces": "error", 71 | "no-duplicate-imports": "error", 72 | "react/jsx-boolean-value": ["error", "never"], 73 | "react-hooks/rules-of-hooks": "error", 74 | "react-hooks/exhaustive-deps": "warn", 75 | "react/jsx-wrap-multilines": ["error", { 76 | "declaration": "parens-new-line", 77 | "assignment": "parens-new-line", 78 | "return": "parens-new-line", 79 | "arrow": "parens-new-line", 80 | "condition": "parens-new-line", 81 | "logical": "parens-new-line", 82 | "prop": "ignore" 83 | }], 84 | "react/jsx-tag-spacing": ["error", { 85 | "closingSlash": "never", 86 | "beforeSelfClosing": "always", 87 | "afterOpening": "never", 88 | "beforeClosing": "proportional-always" 89 | }], 90 | "react/jsx-curly-spacing": ["error", { 91 | "when": "never", 92 | "children": true 93 | }], 94 | "react/self-closing-comp": ["error", { 95 | "component": true, 96 | "html": true 97 | }], 98 | "react/jsx-closing-bracket-location": ["error"], 99 | "@typescript-eslint/ban-ts-comment": [2, { "ts-ignore": "allow-with-description" }], 100 | "@typescript-eslint/no-explicit-any": "off", 101 | "import/no-unresolved": "off", 102 | "import/no-named-as-default": "off", 103 | "import/first": "error", 104 | "import/order": ["error", { 105 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"], 106 | "newlines-between": "always" 107 | }] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /.github/workflows/custom-changelog/action-dist/commit.hbs: -------------------------------------------------------------------------------- 1 | * {{header}} 2 | 3 | {{~!-- commit link --}} {{#if @root.linkReferences~}} 4 | ([{{hash}}]( 5 | {{~#if @root.repository}} 6 | {{~#if @root.host}} 7 | {{~@root.host}}/ 8 | {{~/if}} 9 | {{~#if @root.owner}} 10 | {{~@root.owner}}/ 11 | {{~/if}} 12 | {{~@root.repository}} 13 | {{~else}} 14 | {{~@root.repoUrl}} 15 | {{~/if}}/ 16 | {{~@root.commit}}/{{hash}})) 17 | {{~else}} 18 | {{~hash}} 19 | {{~/if}} 20 | 21 | {{~!-- commit references --}} 22 | {{~#if references~}} 23 | , closes 24 | {{~#each references}} {{#if @root.linkReferences~}} 25 | [ 26 | {{~#if this.owner}} 27 | {{~this.owner}}/ 28 | {{~/if}} 29 | {{~this.repository}}#{{this.issue}}]( 30 | {{~#if @root.repository}} 31 | {{~#if @root.host}} 32 | {{~@root.host}}/ 33 | {{~/if}} 34 | {{~#if this.repository}} 35 | {{~#if this.owner}} 36 | {{~this.owner}}/ 37 | {{~/if}} 38 | {{~this.repository}} 39 | {{~else}} 40 | {{~#if @root.owner}} 41 | {{~@root.owner}}/ 42 | {{~/if}} 43 | {{~@root.repository}} 44 | {{~/if}} 45 | {{~else}} 46 | {{~@root.repoUrl}} 47 | {{~/if}}/ 48 | {{~@root.issue}}/{{this.issue}}) 49 | {{~else}} 50 | {{~#if this.owner}} 51 | {{~this.owner}}/ 52 | {{~/if}} 53 | {{~this.repository}}#{{this.issue}} 54 | {{~/if}}{{/each}} 55 | {{~/if}} 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/custom-changelog/action-dist/footer.hbs: -------------------------------------------------------------------------------- 1 | {{#if noteGroups}} 2 | {{#each noteGroups}} 3 | 4 | ### {{title}} 5 | 6 | {{#each notes}} 7 | * {{text}} 8 | {{/each}} 9 | {{/each}} 10 | {{/if}} 11 | -------------------------------------------------------------------------------- /.github/workflows/custom-changelog/action-dist/header.hbs: -------------------------------------------------------------------------------- 1 | ## {{#if isPatch~}} 2 | {{~/if~}} {{version}} 3 | {{~#if title}} "{{title}}" 4 | {{~/if~}} 5 | {{~#if date}} ({{date}}) 6 | {{~/if~}} 7 | {{~#if isPatch~}} 8 | {{~/if}} 9 | 10 | -------------------------------------------------------------------------------- /.github/workflows/custom-changelog/action-dist/template.hbs: -------------------------------------------------------------------------------- 1 | {{> header}} 2 | 3 | {{#each commitGroups}} 4 | {{#each commits}} 5 | {{> commit root=@root}} 6 | {{/each}} 7 | {{/each}} 8 | 9 | {{> footer}} 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/custom-changelog/action.yml: -------------------------------------------------------------------------------- 1 | name: 'custom-changelog-action' 2 | description: 'Generates changelogs for commitlint commits.' 3 | author: 'Tormak' 4 | inputs: 5 | github-token: 6 | description: "Github token" 7 | default: ${{ github.token }} 8 | required: false 9 | 10 | git-message: 11 | description: "Commit message to use" 12 | default: "chore(release): {version}" 13 | required: false 14 | 15 | git-user-name: 16 | description: "The git user.name to use for the commit" 17 | default: "Conventional Changelog Action" 18 | required: false 19 | 20 | git-user-email: 21 | description: "The git user.email to use for the commit" 22 | default: "conventional.changelog.action@github.com" 23 | required: false 24 | 25 | git-pull-method: 26 | description: "The git pull method used when pulling all changes from remote" 27 | default: "--ff-only" 28 | required: false 29 | 30 | git-branch: 31 | description: "The git branch to be pushed" 32 | default: ${{ github.ref }} 33 | required: false 34 | 35 | tag-prefix: 36 | description: "Prefix that is used for the git tag" 37 | default: "v" 38 | required: false 39 | 40 | git-url: 41 | description: "Git Url" 42 | default: "github.com" 43 | required: false 44 | 45 | outputs: 46 | clean_changelog: 47 | description: "A tidied version of the generated changelog." 48 | 49 | tag: 50 | description: "The tag for the new release." 51 | 52 | version: 53 | description: "The version for the new release." 54 | 55 | runs: 56 | using: 'node16' 57 | main: 'action-dist/index.cjs' 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Coverage reports 17 | coverage 18 | 19 | # API keys and secrets 20 | .env 21 | 22 | # Dependency directory 23 | node_modules 24 | bower_components 25 | .pnpm-store 26 | 27 | # Editors 28 | .idea 29 | *.iml 30 | 31 | # OS metadata 32 | .DS_Store 33 | Thumbs.db 34 | 35 | # Ignore built ts files 36 | dist/ 37 | 38 | __pycache__/ 39 | 40 | /.yalc 41 | yalc.lock 42 | 43 | .vscode/settings.json 44 | 45 | # Ignore output folder 46 | 47 | backend/out 48 | 49 | /py_modules/ 50 | decky_plugin.py -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | npx lint-staged -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "src/**/*.@(js|jsx|ts|tsx)": [ 3 | "eslint" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/config.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]:-$0}"; )" &> /dev/null && pwd 2> /dev/null; )"; 3 | # printf "${SCRIPT_DIR}\n" 4 | # printf "$(dirname $0)\n" 5 | if ! [[ -e "${SCRIPT_DIR}/settings.json" ]]; then 6 | printf '.vscode/settings.json does not exist. Creating it with default settings. Exiting afterwards. Run your task again.\n\n' 7 | cp "${SCRIPT_DIR}/defsettings.json" "${SCRIPT_DIR}/settings.json" 8 | exit 1 9 | else 10 | printf '.vscode/settings.json does exist. Congrats.\n' 11 | printf 'Make sure to change settings.json to match your deck.\n' 12 | fi -------------------------------------------------------------------------------- /.vscode/defsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deckip" : "0.0.0.0", 3 | "deckport" : "22", 4 | "deckpass" : "ssap", 5 | "deckkey" : "-i ${env:HOME}/.ssh/id_rsa", 6 | "deckdir" : "/home/deck" 7 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | // OTHER 5 | { 6 | "label": "checkforsettings", 7 | "type": "shell", 8 | "group": "none", 9 | "detail": "Check that settings.json has been created", 10 | "command": "bash -c ${workspaceFolder}/.vscode/config.sh", 11 | "problemMatcher": [] 12 | }, 13 | // BUILD 14 | { 15 | "label": "pnpmsetup", 16 | "type": "shell", 17 | "group": "build", 18 | "detail": "Setup pnpm", 19 | "command": "pnpm i", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "updatefrontendlib", 24 | "type": "shell", 25 | "group": "build", 26 | "detail": "Update @decky/ui", 27 | "command": "pnpm update @decky/ui --latest", 28 | "problemMatcher": [] 29 | }, 30 | { 31 | "label": "build", 32 | "type": "npm", 33 | "group": "build", 34 | "detail": "rollup -c", 35 | "script": "build", 36 | "path": "", 37 | "problemMatcher": [] 38 | }, 39 | { 40 | "label": "buildall", 41 | "group": "build", 42 | "detail": "Build decky-plugin-template", 43 | "dependsOrder": "sequence", 44 | "dependsOn": [ 45 | "pnpmsetup", 46 | "build" 47 | ], 48 | "problemMatcher": [] 49 | }, 50 | // DEPLOY 51 | { 52 | "label": "createfolders", 53 | "detail": "Create plugins folder in expected directory", 54 | "type": "shell", 55 | "group": "none", 56 | "dependsOn": [ 57 | "checkforsettings" 58 | ], 59 | "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'mkdir -p ${config:deckdir}/homebrew/pluginloader && mkdir -p ${config:deckdir}/homebrew/plugins'", 60 | "problemMatcher": [] 61 | }, 62 | { 63 | "label": "deploy", 64 | "detail": "Deploy dev plugin to deck", 65 | "type": "shell", 66 | "group": "none", 67 | "dependsOn": [ 68 | "createfolders", 69 | "chmodfolders" 70 | ], 71 | "command": "rsync -azp --delete --chmod=D0755,F0755 --rsh='ssh -p ${config:deckport} ${config:deckkey}' --exclude='.git/' --exclude='.github/' --exclude='.vscode/' --exclude='node_modules/' --exclude='src/' --exclude='*.log' --exclude='.gitignore' . deck@${config:deckip}:${config:deckdir}/homebrew/plugins/${workspaceFolderBasename}", 72 | "problemMatcher": [] 73 | }, 74 | { 75 | "label": "chmodfolders", 76 | "detail": "chmods folders to prevent perms issues", 77 | "type": "shell", 78 | "group": "none", 79 | "command": "ssh deck@${config:deckip} -p ${config:deckport} ${config:deckkey} 'echo '${config:deckpass}' | sudo -S chmod -R ug+rw ${config:deckdir}/homebrew/'", 80 | "problemMatcher": [] 81 | }, 82 | { 83 | "label": "deployall", 84 | "dependsOrder": "sequence", 85 | "group": "none", 86 | "dependsOn": [ 87 | "deploy", 88 | "chmodfolders" 89 | ], 90 | "problemMatcher": [] 91 | }, 92 | // ALL-IN-ONE 93 | { 94 | "label": "allinone", 95 | "detail": "Build and deploy", 96 | "dependsOrder": "sequence", 97 | "group": "test", 98 | "dependsOn": [ 99 | "buildall", 100 | "deployall" 101 | ], 102 | "problemMatcher": [] 103 | } 104 | ] 105 | } 106 | -------------------------------------------------------------------------------- /assets/dpad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/howto.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamGridDB/decky-steamgriddb/f9891ee15b55a1b22fad3060131b62bae9a18558/assets/howto.webm -------------------------------------------------------------------------------- /assets/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | pull_request_title: "feat(lang): update translations" 2 | commit_message: "feat(lang): new translations (%language%)" 3 | append_commit_message: false 4 | files: 5 | - source: /src/i18n/strings.json 6 | translation: /src/i18n/%two_letters_code%.json 7 | -------------------------------------------------------------------------------- /defaults/py_modules/vdf/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Rossen Georgiev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/capsule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamGridDB/decky-steamgriddb/f9891ee15b55a1b22fad3060131b62bae9a18558/docs/capsule.png -------------------------------------------------------------------------------- /docs/filters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamGridDB/decky-steamgriddb/f9891ee15b55a1b22fad3060131b62bae9a18558/docs/filters.png -------------------------------------------------------------------------------- /docs/gamecontextmenu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamGridDB/decky-steamgriddb/f9891ee15b55a1b22fad3060131b62bae9a18558/docs/gamecontextmenu.png -------------------------------------------------------------------------------- /docs/manage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamGridDB/decky-steamgriddb/f9891ee15b55a1b22fad3060131b62bae9a18558/docs/manage.png -------------------------------------------------------------------------------- /docs/menucog-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/menucog-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/start-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/start-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/store-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/store-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dump-strings.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const INPUT_PATH = path.resolve('dist/index.js'); 5 | const OUTPUT_PATH = path.resolve('src/i18n/strings.json'); 6 | 7 | if (!fs.existsSync(INPUT_PATH)) { 8 | console.info(`${INPUT_PATH} does not exist, did you forget the build the plugin?`); 9 | process.exit(); 10 | } 11 | 12 | const content = fs.readFileSync(INPUT_PATH, 'utf8'); 13 | 14 | const matches = content.matchAll(/(? x[1]); 17 | 18 | const strings = {}; 19 | for (const [, key, str] of matches) { 20 | if (!steamMatchesFlat.includes(key)) { 21 | strings[key] = str.replace(/(?:\\(.))/, '$1'); 22 | } 23 | } 24 | fs.writeFileSync(OUTPUT_PATH, JSON.stringify(strings, null, 2)); 25 | console.info(`Saved ${Object.keys(strings).length} strings to ${OUTPUT_PATH}`); 26 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from platform import system 3 | from os.path import dirname 4 | from os import W_OK, access, stat 5 | from stat import FILE_ATTRIBUTE_HIDDEN 6 | from urllib.request import Request, urlopen 7 | from urllib.parse import urlparse 8 | from base64 import b64encode 9 | from pathlib import Path 10 | from shutil import copyfile 11 | from settings import SettingsManager # type: ignore 12 | from helpers import get_ssl_context # type: ignore 13 | import decky # type: ignore 14 | 15 | WINDOWS = system() == "Windows" 16 | 17 | if WINDOWS: 18 | from winreg import QueryValueEx, OpenKey, HKEY_CURRENT_USER 19 | 20 | # workaound for py_modules not being added to path on windoge 21 | sys.path.append(decky.DECKY_PLUGIN_DIR) 22 | from py_modules.vdf import binary_dump, binary_load 23 | else: 24 | from vdf import binary_dump, binary_load 25 | 26 | def get_steam_path(): 27 | if WINDOWS: 28 | return Path(QueryValueEx(OpenKey(HKEY_CURRENT_USER, r"Software\Valve\Steam"), "SteamPath")[0]) 29 | else: 30 | return Path(decky.DECKY_USER_HOME) / '.local' / 'share' / 'Steam' 31 | 32 | def get_steam_userdata(): 33 | return get_steam_path() / 'userdata' 34 | 35 | def get_steam_libcache(): 36 | return get_steam_path() / 'appcache' / 'librarycache' 37 | 38 | def get_userdata_config(steam32): 39 | return get_steam_userdata() / steam32 / 'config' 40 | 41 | class Plugin: 42 | async def _main(self): 43 | self.settings = SettingsManager(name="steamgriddb", settings_directory=decky.DECKY_PLUGIN_SETTINGS_DIR) 44 | 45 | async def _unload(self): 46 | pass 47 | 48 | async def download_as_base64(self, url=''): 49 | req = Request(url, headers={'User-Agent': 'decky-steamgriddb backend'}) 50 | content = urlopen(req, context=get_ssl_context()).read() 51 | return b64encode(content).decode('utf-8') 52 | 53 | async def read_file_as_base64(self, path=''): 54 | with open(path, 'rb') as image_file: 55 | return b64encode(image_file.read()).decode('utf-8') 56 | 57 | async def get_local_start(self): 58 | return decky.DECKY_USER_HOME 59 | 60 | async def download_file(self, url='', output_dir='', file_name=''): 61 | decky.logger.debug({url, output_dir, file_name}) 62 | try: 63 | if access(dirname(output_dir), W_OK): 64 | req = Request(url, headers={'User-Agent': 'decky-steamgriddb backend'}) 65 | res = urlopen(req, context=get_ssl_context()) 66 | if res.status == 200: 67 | with open(Path(output_dir) / file_name, mode='wb') as f: 68 | f.write(res.read()) 69 | return str(Path(output_dir) / file_name) 70 | return False 71 | except: 72 | return False 73 | 74 | return False 75 | 76 | async def set_shortcut_icon_from_path(self, appid, owner_id, path): 77 | ext = Path(path).suffix 78 | iconname = "%s_icon%s" % (appid, ext) 79 | output_file = get_userdata_config(owner_id) / 'grid' / iconname 80 | saved_path = str(copyfile(path, output_file)) 81 | return await self.set_shortcut_icon(appid, owner_id, path=saved_path) 82 | 83 | async def set_shortcut_icon_from_url(self, appid, owner_id, url): 84 | output_dir = get_userdata_config(owner_id) / 'grid' 85 | ext = Path(urlparse(url).path).suffix 86 | iconname = "%s_icon%s" % (appid, ext) 87 | saved_path = await self.download_file(url, output_dir, file_name=iconname) 88 | if saved_path: 89 | return await self.set_shortcut_icon(appid, owner_id, path=saved_path) 90 | else: 91 | raise Exception("Failed to download icon from %s" % url) 92 | 93 | async def set_shortcut_icon(self, appid, owner_id, path=None): 94 | shortcuts_vdf = get_userdata_config(owner_id) / 'shortcuts.vdf' 95 | 96 | d = binary_load(open(shortcuts_vdf, "rb")) 97 | for shortcut in d['shortcuts'].values(): 98 | shortcut_appid = (shortcut['appid'] & 0xffffffff) | 0x80000000 99 | if shortcut_appid == appid: 100 | if shortcut['icon'] == path: 101 | return 'icon_is_same_path' 102 | 103 | # Clear icon 104 | if path is None: 105 | shortcut['icon'] = '' 106 | else: 107 | shortcut['icon'] = path 108 | binary_dump(d, open(shortcuts_vdf, 'wb')) 109 | return True 110 | raise Exception('Could not find shortcut to edit') 111 | 112 | async def set_steam_icon_from_url(self, appid, url): 113 | await self.download_file(url, get_steam_libcache(), file_name=("%s_icon.jpg" % appid)) 114 | 115 | async def set_steam_icon_from_path(self, appid, path): 116 | copyfile(path, get_steam_libcache() / str("%s_icon.jpg" % appid)) 117 | 118 | async def set_setting(self, key, value): 119 | self.settings.setSetting(key, value) 120 | 121 | async def get_setting(self, key, fallback): 122 | return self.settings.getSetting(key, fallback) 123 | 124 | async def _migration(self): 125 | decky.migrate_settings(str(Path(decky.DECKY_HOME) / "settings" / "steamgriddb.json")) 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "decky-steamgriddb", 3 | "version": "1.5.1", 4 | "description": "Decky plugin to manage Steam artwork from within gaming mode.", 5 | "type": "module", 6 | "scripts": { 7 | "build": "shx rm -rf dist && rollup -c --environment ROLLUP_ENV:production", 8 | "watch": "rollup -c -w --environment ROLLUP_ENV:development", 9 | "watch:vm-dev": "npm run watch -- --watch.onEnd=\"pnpm run copy-to-vm\"", 10 | "watch:win-dev": "npm run watch -- --watch.onEnd=\"pnpm run copy-to-win\"", 11 | "copy-to-vm": "pscp -P 50658 -pw 123 -r ./dist/ manjaro@127.0.0.1:/home/manjaro/homebrew/plugins/decky-steamgriddb/dist", 12 | "copy-to-win": "robocopy /E /NS /NC /NDL /NFL /NJS /NJH /NP dist \"%USERPROFILE%\\homebrew\\plugins\\decky-steamgriddb\\dist\" || exit 0", 13 | "dump-strings": "node ./dump-strings.js", 14 | "lint": "eslint --ext .ts,.tsx,.js src", 15 | "prepare": "husky install", 16 | "test": "echo \"Error: no test specified\" && exit 1" 17 | }, 18 | "repository": "github:SteamGridDB/decky-steamgriddb", 19 | "keywords": [ 20 | "decky", 21 | "plugin", 22 | "steam-deck", 23 | "deck", 24 | "steamgriddb" 25 | ], 26 | "author": { 27 | "name": "Jozen Blue Martinez", 28 | "email": "me@jozen.blue", 29 | "url": "https://jozen.blue/" 30 | }, 31 | "contributors": [ 32 | { 33 | "name": "Travis Lane (Tormak)", 34 | "email": "Tormak9970@gmail.com" 35 | } 36 | ], 37 | "funding": [ 38 | { 39 | "type": "patreon", 40 | "url": "https://www.patreon.com/steamgriddb" 41 | }, 42 | { 43 | "type": "ko-fi", 44 | "url": "https://ko-fi.com/steamgriddb" 45 | } 46 | ], 47 | "license": "GPL-3.0-or-later", 48 | "bugs": { 49 | "url": "https://github.com/SteamGridDB/decky-steamgriddb/issues" 50 | }, 51 | "homepage": "https://github.com/SteamGridDB/decky-steamgriddb#readme", 52 | "devDependencies": { 53 | "@commitlint/cli": "^17.8.1", 54 | "@commitlint/config-conventional": "^17.8.1", 55 | "@decky/rollup": "^1.0.1", 56 | "@rollup/plugin-commonjs": "^26.0.1", 57 | "@rollup/plugin-json": "^6.1.0", 58 | "@rollup/plugin-node-resolve": "^15.2.3", 59 | "@rollup/plugin-replace": "^5.0.7", 60 | "@rollup/plugin-typescript": "^11.1.6", 61 | "@types/react": "18.2.0", 62 | "@types/webpack": "^5.28.5", 63 | "@typescript-eslint/eslint-plugin": "^5.62.0", 64 | "@typescript-eslint/parser": "^5.62.0", 65 | "eslint": "^8.57.0", 66 | "eslint-import-resolver-typescript": "^3.6.1", 67 | "eslint-plugin-import": "^2.29.0", 68 | "eslint-plugin-react": "^7.33.2", 69 | "eslint-plugin-react-hooks": "^4.6.0", 70 | "husky": "^8.0.3", 71 | "lint-staged": "^14.0.1", 72 | "rollup": "^4.20.0", 73 | "rollup-plugin-delete": "^2.0.0", 74 | "rollup-plugin-external-globals": "^0.11.0", 75 | "rollup-plugin-import-assets": "^1.1.1", 76 | "rollup-plugin-polyfill-node": "^0.13.0", 77 | "rollup-plugin-scss": "^3.0.0", 78 | "sass": "^1.77.8", 79 | "shx": "^0.3.4", 80 | "tslib": "^2.6.2", 81 | "typescript": "^4.9.5" 82 | }, 83 | "dependencies": { 84 | "@decky/api": "^1.1.2", 85 | "@decky/ui": "^4.7.0", 86 | "async-wait-until": "^2.0.12", 87 | "just-debounce": "^1.1.0", 88 | "qrcode.react": "^3.1.0", 89 | "react-fast-compare": "^3.2.2", 90 | "react-icons": "^4.12.0", 91 | "react-markdown": "^8.0.7", 92 | "react-string-replace": "^1.1.1", 93 | "remark-gfm": "^3.0.1" 94 | }, 95 | "pnpm": { 96 | "peerDependencyRules": { 97 | "ignoreMissing": [ 98 | "react", 99 | "react-dom" 100 | ] 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "api_version": 1, 3 | "name": "SteamGridDB", 4 | "author": "SteamGridDB", 5 | "flags": ["debug"], 6 | "publish": { 7 | "tags": ["artwork", "sgdb"], 8 | "description": "Customize your library with user-submitted images or your local files, and apply other tweaks like changing the shape of the recently played game capsule, making them square, and more!", 9 | "image": "https://raw.githubusercontent.com/SteamGridDB/decky-steamgriddb/main/thumb.png" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import json from '@rollup/plugin-json'; 6 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 7 | import typescript from '@rollup/plugin-typescript'; 8 | import importAssets from 'rollup-plugin-import-assets'; 9 | import { defineConfig } from 'rollup'; 10 | import scss from 'rollup-plugin-scss'; 11 | import nodePolyfills from 'rollup-plugin-polyfill-node'; 12 | import externalGlobals from 'rollup-plugin-external-globals'; 13 | import replace from '@rollup/plugin-replace'; 14 | import del from 'rollup-plugin-delete'; 15 | import * as sass from 'sass'; 16 | 17 | const manifest = JSON.parse(readFileSync(join('.', 'plugin.json'), 'utf-8')); 18 | 19 | export default defineConfig({ 20 | input: './src/index.tsx', 21 | plugins: [ 22 | del({ targets: './dist/*', force: true }), 23 | json({ compact: true }), 24 | typescript({ 25 | include: ['src/**/*.ts', 'src/**/*.tsx'], 26 | }), 27 | commonjs(), 28 | nodePolyfills(), 29 | nodeResolve({ 30 | browser: true, 31 | }), 32 | scss({ 33 | output: false, 34 | sourceMap: false, 35 | include: ['src/styles/**/*.scss', 'src/styles/**/*.sass'], 36 | watch: 'src/styles', 37 | sass: sass, 38 | }), 39 | externalGlobals({ 40 | react: 'SP_REACT', 41 | 'react-dom': 'SP_REACTDOM', 42 | '@decky/ui': 'DFL', 43 | '@decky/manifest': JSON.stringify(manifest), 44 | }), 45 | replace({ 46 | preventAssignment: false, 47 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), 48 | 'process.env.ROLLUP_ENV': JSON.stringify(process.env.ROLLUP_ENV), 49 | }), 50 | importAssets({ 51 | publicPath: `http://127.0.0.1:1337/plugins/${manifest.name}/`, 52 | include:[ 53 | /\.gif$/i, 54 | /\.jpg$/i, 55 | /\.png$/i, 56 | /\.svg$/i, 57 | /\.webm$/i, 58 | /\.webp$/i, 59 | /\.mp4$/i, 60 | ], 61 | }), 62 | ], 63 | context: 'window', 64 | external: ['react', 'react-dom', '@decky/ui'], 65 | treeshake: { 66 | // Assume all external modules have imports with side effects (the default) while allowing decky libraries to treeshake 67 | pureExternalImports: { 68 | pure: ['@decky/ui', '@decky/api'], 69 | }, 70 | preset: 'smallest', 71 | }, 72 | output: { 73 | dir: 'dist', 74 | format: 'esm', 75 | sourcemap: true, 76 | sourcemapPathTransform: (relativeSourcePath) => relativeSourcePath.replace(/^\.\.\//, `decky://decky/plugin/${encodeURIComponent(manifest.name)}/`), 77 | exports: 'default', 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/AppGridFilterBar.tsx: -------------------------------------------------------------------------------- 1 | import { findModuleExport, Export } from '@decky/ui'; 2 | import { FC, HTMLAttributes } from 'react'; 3 | 4 | export const appGridFilterHeaderClass = findModuleExport((e: Export, name: any) => typeof e === 'string' && name === 'AppGridFilterHeader'); 5 | 6 | const appGridFilterTextClass = findModuleExport((e: Export, name: any) => typeof e === 'string' && name === 'AppGridFilterText'); 7 | 8 | const AppGridFilterBar: FC> = ({ children, ...rest }) => ( 9 |
10 |
11 | 12 | {children} 13 | 14 |
15 |
16 | ); 17 | 18 | export default AppGridFilterBar; -------------------------------------------------------------------------------- /src/components/Chevron.tsx: -------------------------------------------------------------------------------- 1 | import { FC, SVGProps } from 'react'; 2 | 3 | interface ChevronProps extends SVGProps { 4 | direction: 'up' | 'down' | 'left' | 'right', 5 | } 6 | 7 | const Chevron: FC = ({ direction, ...rest }) => { 8 | let d: string; 9 | switch (direction) { 10 | case 'up': 11 | d = 'M17.98 10.23L3.20996 25H32.75L17.98 10.23Z'; 12 | break; 13 | case 'down': 14 | d = 'M17.98 26.54L3.20996 11.77H32.75L17.98 26.54Z'; 15 | break; 16 | case 'left': 17 | d = 'M9.82497 18.385L24.595 3.61499L24.595 33.155L9.82497 18.385Z'; 18 | break; 19 | case 'right': 20 | d = 'M26.135 18.385L11.365 33.155L11.365 3.61503L26.135 18.385Z'; 21 | break; 22 | } 23 | return ( 24 | 30 | 34 | 35 | ); 36 | }; 37 | 38 | export default Chevron; 39 | -------------------------------------------------------------------------------- /src/components/Chips/Chip.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react'; 2 | 3 | const Chip: FC<{ 4 | color: string, 5 | colorText?: string, 6 | children: string | ReactNode 7 | }> = ({ color, colorText, children }) => ( 8 |
  • 15 | {children} 16 |
  • 17 | ); 18 | 19 | export default Chip; -------------------------------------------------------------------------------- /src/components/Chips/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FC, 3 | useLayoutEffect, 4 | useState, 5 | useRef, 6 | useCallback, 7 | ReactNode, 8 | } from 'react'; 9 | import { joinClassNames, findSP } from '@decky/ui'; 10 | import debounce from 'just-debounce'; 11 | 12 | const Chips: FC<{ children: ReactNode }> = ({ children }) => { 13 | const chipsRef = useRef(null); 14 | const [leftAlign, setLeftAlign] = useState(false); 15 | 16 | const isCutOff = useCallback(() => { 17 | const el = chipsRef.current; 18 | if (!el) return false; 19 | const sizeEl = el.parentElement; // element we can check size aginst 20 | if (sizeEl) { 21 | return sizeEl.getBoundingClientRect().right + el.clientWidth + 10 > findSP().innerWidth; 22 | } 23 | return false; 24 | }, []); 25 | 26 | // Check if should switch to left aligned every time size is changed 27 | useLayoutEffect(() => { 28 | const el = chipsRef.current; 29 | if (!el) return; 30 | const observer = new MutationObserver(debounce((mutations) => { 31 | if (mutations[0].attributeName === 'style') { 32 | if (el.parentElement) { 33 | setLeftAlign(isCutOff()); 34 | } 35 | } 36 | }, 500)); 37 | 38 | const assetContainer = el.closest('#images-container'); 39 | if (assetContainer) { 40 | observer.observe(assetContainer, { attributes: true }); 41 | } 42 | // Inital 43 | setLeftAlign(isCutOff()); 44 | return () => { 45 | observer.disconnect(); 46 | }; 47 | }, [isCutOff]); 48 | 49 | return
      {children}
    ; 50 | }; 51 | 52 | export default Chips; -------------------------------------------------------------------------------- /src/components/DropdownMultiselect.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | showContextMenu, 3 | DialogButton, 4 | Menu, 5 | MenuItem, 6 | DialogCheckbox, 7 | DialogCheckboxProps, 8 | Marquee, 9 | } from '@decky/ui'; 10 | import { FC, useState, useEffect, useCallback, ReactNode } from 'react'; 11 | 12 | import Chevron from '../components/Chevron'; 13 | import t from '../utils/i18n'; 14 | 15 | const DropdownMultiselectItem: FC<{ 16 | value: any, 17 | onSelect: (checked: boolean, value: any) => void, 18 | checked: boolean 19 | } & DialogCheckboxProps> = ({ 20 | value, 21 | onSelect, 22 | checked: defaultChecked, 23 | ...rest 24 | }) => { 25 | const [checked, setChecked] = useState(defaultChecked); 26 | 27 | useEffect(() => { 28 | onSelect?.(checked, value); 29 | }, [checked, onSelect, value]); 30 | 31 | return ( 32 | setChecked((x) => !x)} 35 | > 36 | setChecked((x) => !x)} 42 | onChange={(checked) => setChecked(checked)} 43 | controlled 44 | checked={checked} 45 | /> 46 | 47 | ); 48 | }; 49 | 50 | const DropdownMultiselect: FC<{ 51 | items: { 52 | label: string, 53 | value: any, 54 | }[], 55 | selected: any[], 56 | onSelect: (selected: any[]) => void, 57 | label: string 58 | }> = ({ 59 | label, 60 | items, 61 | selected, 62 | onSelect, 63 | }) => { 64 | const [itemsSelected, setItemsSelected] = useState(selected); 65 | 66 | const handleItemSelect = useCallback((checked: boolean, value: string | ReactNode) => { 67 | setItemsSelected((x: any) => (checked ? 68 | [...x.filter((y: any) => y !== value), value] : 69 | x.filter((y: any) => y !== value) 70 | )); 71 | }, []); 72 | 73 | useEffect(() => { 74 | onSelect(itemsSelected); 75 | }, [itemsSelected, onSelect]); 76 | 77 | return ( 78 | { 85 | evt.preventDefault(); 86 | showContextMenu( 87 | 91 | 106 | {items.map((x) => ( 107 | 114 | ))} 115 | , 116 | evt.currentTarget ?? window 117 | ); 118 | }} 119 | > 120 | 121 | { 122 | selected.length > 0 ? 123 | selected.map((x: any) => items[items.findIndex((v) => v.value === x)].label).join(', ') : 124 | '…' 125 | } 126 | 127 |
    128 | 129 | 130 | ); 131 | }; 132 | 133 | export default DropdownMultiselect; 134 | -------------------------------------------------------------------------------- /src/components/FooterGlyph.tsx: -------------------------------------------------------------------------------- 1 | import { FC, CSSProperties } from 'react'; 2 | import { findModuleExport, Export } from '@decky/ui'; 3 | 4 | export enum FooterGlyphType { Knockout, Light, Dark } 5 | 6 | export enum FooterGlyphSize { Small, Medium, Large } 7 | 8 | const FooterGlyph: FC<{ 9 | button: number, 10 | type?: FooterGlyphType, 11 | size?: FooterGlyphSize, 12 | style?: CSSProperties, 13 | }> = findModuleExport((e: Export) => e?.toString && e.toString().includes('.Knockout') && e.toString().includes('.additionalClassName')); 14 | 15 | export default FooterGlyph; -------------------------------------------------------------------------------- /src/components/Icons/BoopIcon.tsx: -------------------------------------------------------------------------------- 1 | import { VFC } from 'react'; 2 | 3 | const BoopIcon: VFC = (props) => ( 4 | 16 | 17 | 18 | 19 | ); 20 | 21 | export default BoopIcon; 22 | 23 | -------------------------------------------------------------------------------- /src/components/Icons/EshopIcon.tsx: -------------------------------------------------------------------------------- 1 | import { VFC } from 'react'; 2 | 3 | const EshopIcon: VFC = (props) => ( 4 | 14 | 15 | 16 | ); 17 | 18 | export default EshopIcon; 19 | 20 | -------------------------------------------------------------------------------- /src/components/Icons/FlashpointIcon.tsx: -------------------------------------------------------------------------------- 1 | import { VFC } from 'react'; 2 | 3 | const FlashpointIcon: VFC = (props) => ( 4 | 14 | 15 | 16 | 17 | ); 18 | 19 | export default FlashpointIcon; 20 | 21 | -------------------------------------------------------------------------------- /src/components/Icons/GogIcon.tsx: -------------------------------------------------------------------------------- 1 | import { VFC } from 'react'; 2 | 3 | const GogIcon: VFC = (props) => ( 4 | 14 | 15 | 16 | ); 17 | 18 | export default GogIcon; 19 | 20 | -------------------------------------------------------------------------------- /src/components/Icons/MenuIcon.tsx: -------------------------------------------------------------------------------- 1 | import { VFC } from 'react'; 2 | 3 | // this will be blurry unless you're on 1x UI scale cause the quick access menu icons arent being scaled pixel perfect :( 4 | const MenuIcon: VFC = (props) => ( 5 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | export default MenuIcon; 22 | 23 | -------------------------------------------------------------------------------- /src/components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { Focusable, FocusableProps, Navigation } from '@decky/ui'; 3 | import { FC } from 'react'; 4 | import ReactMarkdown, { Options } from 'react-markdown'; 5 | import remarkGfm from 'remark-gfm'; 6 | import { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; 7 | 8 | import t from '../utils/i18n'; 9 | import showQrModal from '../utils/showQrModal'; 10 | 11 | const Markdown: FC<{ 12 | onLinkClick?: () => void, 13 | focusableProps?: Partial 14 | } & Options> = ({ 15 | onLinkClick, 16 | focusableProps, 17 | children, 18 | ...props 19 | }) => { 20 | return ( 21 | 22 | ( 30 | { 32 | if (linkProps.href) { 33 | Navigation.NavigateToExternalWeb(linkProps.href); 34 | onLinkClick?.(); 35 | } 36 | }} 37 | onSecondaryButton={() => showQrModal(linkProps.href ?? '')} 38 | onSecondaryActionDescription={t('ACTION_SHOW_LINK_QR', 'Show Link QR')} 39 | style={{ display: 'inline-block' }} 40 | > 41 | 42 | {children} 43 | 44 | 45 | ), 46 | }} 47 | {...props} 48 | > 49 | {children} 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default Markdown; -------------------------------------------------------------------------------- /src/components/ResultsStateBar.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import reactStringReplace from 'react-string-replace'; 3 | import { Marquee } from '@decky/ui'; 4 | 5 | import t from '../utils/i18n'; 6 | 7 | import AppGridFilterBar from './AppGridFilterBar'; 8 | import FooterGlyph from './FooterGlyph'; 9 | 10 | const strGameSelected = t('MSG_GAME_SELECTED', 'Selected {gameName}'); 11 | const strFilterActive = t('MSG_ASSETS_FILTERED', 'Some assets may be hidden due to filter'); 12 | const strFilterAndGame = t('MSG_GAME_SELECTED_AND_ASSETS_FILTERED', 'Selected {gameName} with filter'); 13 | 14 | const ResultsStateBar: FC<{ 15 | loading: boolean; 16 | selectedGame: any; 17 | isFiltered: boolean; 18 | onClick: () => void; 19 | }> = ({ loading, selectedGame, isFiltered, onClick }) => { 20 | if (loading) return null; 21 | if (selectedGame && !isFiltered) { 22 | return ( 23 | 24 | {reactStringReplace(strGameSelected, '{gameName}', (_, i) => ( 25 | "{selectedGame.name}" 26 | ))} 27 | 28 | 29 | ); 30 | } 31 | if (!selectedGame && isFiltered) { 32 | return ( 33 | 34 | {strFilterActive} 35 | 36 | ); 37 | } 38 | if (selectedGame && isFiltered) { 39 | return ( 40 | 41 | {reactStringReplace(strFilterAndGame, '{gameName}', (_, i) => ( 42 | "{selectedGame.name}" 43 | ))} 44 | 45 | 46 | ); 47 | } 48 | return null; 49 | }; 50 | 51 | export default ResultsStateBar; 52 | -------------------------------------------------------------------------------- /src/components/asset/Asset.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { Focusable, FocusableProps, FooterLegendProps, joinClassNames } from '@decky/ui'; 3 | 4 | import t from '../../utils/i18n'; 5 | import Spinner from '../../../assets/spinner.svg'; 6 | import FooterGlyph from '../FooterGlyph'; 7 | import Chips from '../Chips'; 8 | import Chip from '../Chips/Chip'; 9 | 10 | import { LazyImage } from './LazyImage'; 11 | 12 | export interface AssetProps extends FooterLegendProps, Omit { 13 | assetType: SGDBAssetType; 14 | width: number; 15 | height: number; 16 | src: string; 17 | author?: any; 18 | isAnimated: boolean; 19 | isDownloading?: boolean; 20 | onActivate?: FocusableProps['onActivate']; 21 | scrollContainer?: Element; 22 | notes?: string; 23 | nsfw?: boolean; 24 | humor?: boolean; 25 | epilepsy?: boolean; 26 | onImgError?: React.ReactEventHandler; 27 | } 28 | 29 | const Asset: FC = ({ 30 | assetType, 31 | width, 32 | height, 33 | src, 34 | author, 35 | isAnimated, 36 | onActivate, 37 | isDownloading = false, 38 | scrollContainer, 39 | notes = null, 40 | nsfw, 41 | humor, 42 | epilepsy, 43 | onImgError, 44 | ...rest 45 | }) => ( 46 |
    47 | 53 |
    54 | 55 | {notes ? ( 56 | 57 | {t('LABEL_NOTES', 'Notes')} 58 | 59 | ) : null} 60 | {isAnimated ? ( 61 | 62 | {t('LABEL_ANIMATED', 'Animated')} 63 | 64 | ) : null} 65 | {nsfw ? ( 66 | 67 | {t('LABEL_NSFW', 'Adult Content')} 68 | 69 | ) : null} 70 | {humor ? ( 71 | 72 | {t('LABEL_HUMOR', 'Humor')} 73 | 74 | ) : null} 75 | {epilepsy ? ( 76 | 77 | {t('LABEL_EPILEPSY', 'Epilepsy')} 78 | 79 | ) : null} 80 | 81 | 93 |
    94 | {author && ( 95 |
    96 | 97 | {author.name} 98 |
    99 | )} 100 |
    101 | ); 102 | 103 | export default Asset; -------------------------------------------------------------------------------- /src/components/asset/LazyImage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FC, 3 | useEffect, 4 | useState, 5 | useRef, 6 | SVGAttributes, 7 | ImgHTMLAttributes, 8 | } from 'react'; 9 | import { IconsModule } from '@decky/ui'; 10 | 11 | // @todo: find a better way to get this 12 | const ErrorIcon = Object.values(IconsModule).find((mod: any) => mod?.toString().includes('M27.7974 10L26.6274 2H33.3674L32.2374 10H27.7974Z')) as FC>; 13 | 14 | interface LazyImage extends ImgHTMLAttributes { 15 | isVideo?: boolean, 16 | unloadWhenOutside?: boolean, 17 | marginOffset?: IntersectionObserverInit['rootMargin']; 18 | scrollContainer?: IntersectionObserverInit['root']; 19 | src: string, 20 | wrapperProps?: any, 21 | blurBackground?: boolean, 22 | } 23 | 24 | export const LazyImage: FC = ({ 25 | isVideo = false, 26 | unloadWhenOutside = false, 27 | marginOffset, 28 | scrollContainer, 29 | src, 30 | wrapperProps, 31 | blurBackground = false, 32 | ...props 33 | }) => { 34 | const [inViewport, setInViewport] = useState(false); 35 | const [loading, setLoading] = useState(true); 36 | const [error, setError] = useState(false); 37 | const imgRef = useRef(null); 38 | const intersectRef = useRef(null); 39 | 40 | // reset some state when src changes 41 | useEffect(() => { 42 | setInViewport(false); 43 | setError(false); 44 | setLoading(true); 45 | }, [src]); 46 | 47 | useEffect(() => { 48 | if (!imgRef.current) return; 49 | const img = imgRef.current; 50 | const onLoad = () => { 51 | if (isVideo) { 52 | (img as HTMLVideoElement).play(); 53 | } 54 | setLoading(false); 55 | }; 56 | const onError = () => setError(true); 57 | 58 | if (isVideo) { 59 | img.addEventListener('canplaythrough', onLoad); 60 | } else { 61 | img.addEventListener('load', onLoad); 62 | } 63 | img.addEventListener('error', onError); 64 | return () => { 65 | if (isVideo) { 66 | img.removeEventListener('canplaythrough', onLoad); 67 | } else { 68 | img.removeEventListener('load', onLoad); 69 | } 70 | img.removeEventListener('error', onError); 71 | }; 72 | }, [src, isVideo, inViewport]); 73 | 74 | useEffect(() => { 75 | if (!intersectRef.current) return; 76 | 77 | const observer = new IntersectionObserver((entries, observer) => { 78 | entries.forEach((entry) => { 79 | if (entry.isIntersecting && entry.intersectionRatio >= .5) { 80 | setInViewport(true); 81 | if (!unloadWhenOutside) { 82 | observer.unobserve(entry.target); 83 | } 84 | } else if (unloadWhenOutside && !loading && entry.intersectionRatio === 0) { 85 | /* If completely out of view and already loaded, reset state. 86 | images/videos should be cached by CEF so when back to view they will load instantly */ 87 | setInViewport(false); 88 | setLoading(true); 89 | } 90 | }); 91 | }, { threshold: [.5, 0], rootMargin: marginOffset, root: scrollContainer }); 92 | 93 | observer.observe(intersectRef.current); 94 | return () => { 95 | observer.disconnect(); 96 | }; 97 | }, [loading, marginOffset, unloadWhenOutside, scrollContainer, src]); 98 | 99 | return ( 100 |
    109 | {error && } 110 | 111 | {(inViewport && !isVideo && error !== true) && ( 112 | <> 113 | {blurBackground && ( 114 | 120 | )} 121 | } 123 | data-loaded={loading ? 'false' : 'true'} 124 | src={src} 125 | {...props} 126 | /> 127 | 128 | )} 129 | 130 | {(inViewport && isVideo && error !== true) && ( 131 |
    143 | ); 144 | }; 145 | 146 | export default LazyImage; 147 | -------------------------------------------------------------------------------- /src/components/asset/LibraryImage.tsx: -------------------------------------------------------------------------------- 1 | import { FC, CSSProperties } from 'react'; 2 | import { findModuleExport, SteamAppOverview, Export } from '@decky/ui'; 3 | 4 | export interface LibraryImageProps { 5 | app?: SteamAppOverview; 6 | rgSources?: any; 7 | appid?: number; 8 | eAssetType: eAssetType; 9 | className?: string; 10 | imageClassName?: string; 11 | allowCustomization?: boolean; 12 | neverShowTitle?: boolean; 13 | name?: string; 14 | suppressTransitions?: boolean; 15 | bShortDisplay?: boolean; 16 | backgroundType?: 'transparent'; 17 | onIncrementalError?: (evt: Event, t: any, r: any) => void; 18 | onLoad?: (evt: Event) => void; 19 | onError?: (evt: Event) => void; 20 | style?: CSSProperties; 21 | } 22 | 23 | const LibraryImage = findModuleExport((e: Export) => e?.toString && e.toString().includes('Either rgSources or app must be specified')) as FC; 24 | 25 | export default LibraryImage; -------------------------------------------------------------------------------- /src/components/plugin-pages/AssetTabs.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { memo, useEffect, useState, FC } from 'react'; 3 | import { Tabs, TabsProps, showModal } from '@decky/ui'; 4 | 5 | import t from '../../utils/i18n'; 6 | import useAssetSearch from '../../hooks/useAssetSearch'; 7 | import useSGDB from '../../hooks/useSGDB'; 8 | import { tabStrs, DEFAULT_TABS } from '../../constants'; 9 | import useSettings from '../../hooks/useSettings'; 10 | import BeggingModal from '../../modals/BeggingModal'; 11 | 12 | import ManageTab from './ManageTab'; 13 | import AssetTab from './AssetTab'; 14 | 15 | const AssetTabs: FC<{ 16 | currentTab: string, 17 | onShowTab: TabsProps['onShowTab'] 18 | }> = ({ currentTab, onShowTab }) => { 19 | const { get, set } = useSettings(); 20 | const { appOverview } = useSGDB(); 21 | const { openFilters } = useAssetSearch(); 22 | const [tabPositions, setTabPositions] = useState(null); 23 | const [hiddenTabs, setHiddenTabs] = useState(null); 24 | 25 | useEffect(() => { 26 | (async () => { 27 | setTabPositions(await get('tabs_order', DEFAULT_TABS)); 28 | setHiddenTabs(await get('tabs_hidden', [])); 29 | 30 | // Amount of times tabs page opened, used to hide tutorial after a while 31 | const useCount = await get('plugin_use_count', 0); 32 | set('plugin_use_count', useCount + 1, true); 33 | 34 | // Donation message if plugin is used a lot 35 | if ([15, 75, 200, 1337].includes(useCount)) { 36 | showModal(); 37 | } 38 | })(); 39 | }, [get, set]); 40 | 41 | if (!tabPositions || !hiddenTabs) return null; 42 | 43 | let tabs = [ 44 | ...tabPositions 45 | /* 46 | Filter out icons if: 47 | - App is a mod, editing edit liblist.gam/gameinfo.txt is destructive and out of scope for this plugin 48 | - Shortcut is not locally installed, can't edit shortcuts.vdf remotely. 49 | */ 50 | .filter((type) => !(type === 'icon' && ( 51 | appOverview.third_party_mod || 52 | (appOverview.BIsShortcut() && appOverview.selected_clientid != '0') 53 | ))) 54 | // Filter hidden tabs 55 | .filter((x) => !hiddenTabs.includes(x)), 56 | ]; 57 | 58 | /* 59 | If no tabs are left, force show manage tab. Useful for when only icons are selected and trying to view a mod. 60 | */ 61 | if (tabs.length === 0) { 62 | tabs = ['manage']; 63 | } 64 | 65 | return ( 66 | { 71 | if (type === 'manage') { 72 | return { 73 | title: tabStrs[type], 74 | content: , 75 | id: 'manage', 76 | }; 77 | } 78 | return { 79 | id: type, 80 | title: tabStrs[type as SGDBAssetType], 81 | content: , 82 | footer: { 83 | onSecondaryActionDescription: t('ACTION_OPEN_FILTER', 'Filter'), 84 | onSecondaryButton: () => openFilters(type as SGDBAssetType), 85 | }, 86 | }; 87 | })} 88 | /> 89 | ); 90 | }; 91 | 92 | export default memo(AssetTabs); -------------------------------------------------------------------------------- /src/components/plugin-pages/SGDBPage.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from '@decky/ui'; 2 | import { useEffect, useState, VFC, useCallback } from 'react'; 3 | 4 | import { AssetSearchContext } from '../../hooks/useAssetSearch'; 5 | import { useSGDB } from '../../hooks/useSGDB'; 6 | import useSettings from '../../hooks/useSettings'; 7 | import { DEFAULT_TABS } from '../../constants'; 8 | import style from '../../styles/style.scss'; 9 | 10 | import AssetTabs from './AssetTabs'; 11 | 12 | const SGDBPage: VFC = () => { 13 | const { get } = useSettings(); 14 | const { setAppId, appOverview } = useSGDB(); 15 | const { appid, assetType = 'grid_p' } = useParams<{ appid: string, assetType: SGDBAssetType | 'manage' }>(); 16 | const [currentTab, setCurrentTab] = useState(); 17 | 18 | const onShowTab = useCallback((tabID: string) => { 19 | setCurrentTab(tabID); 20 | }, []); 21 | 22 | useEffect(() => { 23 | setAppId(parseInt(appid, 10)); 24 | }, [appid, setAppId]); 25 | 26 | useEffect(() => { 27 | (async () => { 28 | const positions: SGDBAssetType[] = await get('tabs_order', DEFAULT_TABS); 29 | const hidden: SGDBAssetType[] = await get('tabs_hidden', []); 30 | let tabDefault = await get('tab_default', assetType); 31 | const filtered = positions.filter((x) => !hidden.includes(x)); 32 | 33 | // Set first tab as default if default is hidden 34 | if (!filtered.includes(tabDefault)) { 35 | tabDefault = filtered[0]; 36 | } 37 | setCurrentTab(tabDefault); 38 | })(); 39 | }, [get, assetType]); 40 | 41 | if (!appOverview || !currentTab) return null; 42 | 43 | return ( 44 | <> 45 | 48 |
    49 | 50 | 51 | 52 |
    53 | 54 | ); 55 | }; 56 | 57 | export default SGDBPage; -------------------------------------------------------------------------------- /src/components/qam-contents/GuideVideoField.tsx: -------------------------------------------------------------------------------- 1 | import { Field, ProgressBar } from '@decky/ui'; 2 | import { useState } from 'react'; 3 | import { HiOutlineChevronRight } from 'react-icons/hi2'; 4 | import reactStringReplace from 'react-string-replace'; 5 | 6 | import HowToVideo from '../../../assets/howto.webm'; 7 | import FooterGlyph from '../FooterGlyph'; 8 | import t from '../../utils/i18n'; 9 | 10 | const strInstructions = t('MSG_USAGE_INSTRUCTIONS', 'Select a game {arrow} {optionsButton} {arrow} "{ACTION_CHANGE_ARTWORK}"') 11 | .replace('{ACTION_CHANGE_ARTWORK}', t('ACTION_CHANGE_ARTWORK', 'Change Artwork...')); 12 | const changeInstructions = reactStringReplace(reactStringReplace(strInstructions, '{arrow}', (_, i) => ( 13 | 14 | )), '{optionsButton}', (_, i) => ( 15 | 16 | )); 17 | 18 | const GuideVideoField: typeof Field = (props) => { 19 | const [duration, setDuration] = useState(0); 20 | const [progress, setProgress] = useState(0); 21 | 22 | const handlePlay = (evt: React.SyntheticEvent) => { 23 | const target = (evt.target as HTMLVideoElement); 24 | setDuration(target.duration); 25 | setProgress(100); 26 | }; 27 | 28 | const handleEnded = (evt: React.SyntheticEvent) => { 29 | const target = (evt.target as HTMLVideoElement); 30 | setDuration(0.4); 31 | setProgress(0); 32 | 33 | // replay after .5s 34 | setTimeout(() => { 35 | target.play(); 36 | }, 500); 37 | }; 38 | 39 | return ( 40 | 53 | {changeInstructions} 54 |
    55 | } 56 | {...props} 57 | > 58 |