├── .eslintignore ├── src ├── components │ ├── styles │ │ ├── PopupFix.scss │ │ ├── ForceCodec.scss │ │ ├── ForceResolution.scss │ │ ├── Clock.scss │ │ ├── LookingForGroup.scss │ │ ├── Ratings.scss │ │ ├── NetworkMonitor.scss │ │ ├── UITab.scss │ │ ├── StadiaPlusDBHook.scss │ │ └── StoreFilter.scss │ ├── Clock.ts │ ├── PasteFromClipboard.ts │ ├── StadiaPlusDBHook.ts │ ├── AllowWindowedMode.ts │ └── Ratings.ts ├── models │ ├── NavPosition.ts │ ├── CheckboxInstance.ts │ ├── SwitchStyle.ts │ ├── CheckboxStyle.ts │ ├── CheckboxShape.ts │ ├── AppdataManifest.ts │ ├── UIRowOptions.ts │ ├── OrderDirection.ts │ ├── SelectStyle.ts │ ├── CheckboxColor.ts │ ├── CheckboxAnimation.ts │ ├── SelectOptions.ts │ ├── AFLibraryData.ts │ ├── Codec.ts │ ├── Resolution.ts │ ├── CaptureItem.ts │ └── FilterOrder.ts ├── styles │ ├── Grid.scss │ ├── Typography.scss │ └── Global.scss ├── popup │ └── src │ │ ├── assets │ │ ├── logo.png │ │ ├── global.css │ │ └── Google G.svg │ │ ├── components │ │ ├── Icon.vue │ │ ├── Spinner.vue │ │ ├── SelectBox.vue │ │ ├── Button.vue │ │ ├── PageHeader.vue │ │ ├── Dropdown.vue │ │ ├── PageButton.vue │ │ ├── HelloWorld.vue │ │ └── Profile.vue │ │ ├── main.js │ │ ├── DeveloperPage.vue │ │ ├── WipeDataPage.vue │ │ ├── UserPage.vue │ │ ├── MainPage.vue │ │ ├── SettingsPage.vue │ │ ├── App.vue │ │ └── ComponentPage.vue ├── appdata.json ├── Shortcut.ts ├── Browser.ts ├── ui │ ├── styles │ │ ├── Button.scss │ │ ├── Modal.scss │ │ ├── Snackbar.scss │ │ └── Select.scss │ ├── Snackbar.ts │ ├── UIRow.ts │ ├── UIButtonContainer.ts │ ├── Modal.ts │ ├── Switch.ts │ ├── NavButton.ts │ ├── UIComponent.ts │ ├── Select.ts │ ├── UIButton.ts │ └── Checkbox.ts ├── util │ ├── EventTracker.ts │ ├── Util.ts │ ├── ElGen.ts │ └── WebScraperRunnable.js ├── logger.ts ├── WebDatabase.ts ├── Component.ts ├── ComponentLoader.ts ├── index.js ├── Storage.ts ├── Language.ts ├── StadiaPlusDB.ts └── lang │ ├── gl-ES.json │ ├── nl-BE.json │ ├── eu-ES.json │ ├── ru-RU.json │ ├── de-DE.json │ ├── es-ES.json │ └── en-US.json ├── images ├── Stadia+.ai ├── PlayButton.png ├── Stadia+128.png ├── Stadia+16.png ├── Stadia+32.png ├── Stadia+48.png ├── legacy │ ├── Stadia+16.png │ ├── Stadia+32.png │ ├── Stadia+48.png │ ├── Stadia+128.png │ └── Stadia_logo.svg ├── PlayButtonBackground.png ├── icons │ ├── filter.svg │ ├── windowed.svg │ ├── windowed_exit.svg │ ├── network-monitor.svg │ ├── visibility.svg │ ├── visibility_off.svg │ ├── search.svg │ └── stadiaplus.svg └── Stadia+.svg ├── typedoc.json ├── docs └── assets │ └── images │ ├── icons.png │ ├── icons@2x.png │ ├── widgets.png │ └── widgets@2x.png ├── .prettierrc ├── CONTRIBUTING.md ├── tsconfig.json ├── background.js ├── custom.d.ts ├── README.md ├── .gitignore ├── package.json ├── manifest.json ├── webpack.config.js └── .eslintrc.js /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | **/*.js 3 | dist/** -------------------------------------------------------------------------------- /src/components/styles/PopupFix.scss: -------------------------------------------------------------------------------- 1 | .zLoQpb.offset { 2 | margin-top: 5rem; 3 | } -------------------------------------------------------------------------------- /images/Stadia+.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/Stadia+.ai -------------------------------------------------------------------------------- /src/models/NavPosition.ts: -------------------------------------------------------------------------------- 1 | export enum NavPosition { 2 | LEFT, 3 | RIGHT 4 | } 5 | -------------------------------------------------------------------------------- /images/PlayButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/PlayButton.png -------------------------------------------------------------------------------- /images/Stadia+128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/Stadia+128.png -------------------------------------------------------------------------------- /images/Stadia+16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/Stadia+16.png -------------------------------------------------------------------------------- /images/Stadia+32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/Stadia+32.png -------------------------------------------------------------------------------- /images/Stadia+48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/Stadia+48.png -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputFiles": ["./src"], 3 | "mode": "modules", 4 | "out": "docs" 5 | } -------------------------------------------------------------------------------- /images/legacy/Stadia+16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/legacy/Stadia+16.png -------------------------------------------------------------------------------- /images/legacy/Stadia+32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/legacy/Stadia+32.png -------------------------------------------------------------------------------- /images/legacy/Stadia+48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/legacy/Stadia+48.png -------------------------------------------------------------------------------- /src/styles/Grid.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_row { 2 | display: inline-flex; 3 | align-items: flex-end; 4 | } -------------------------------------------------------------------------------- /docs/assets/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/docs/assets/images/icons.png -------------------------------------------------------------------------------- /images/legacy/Stadia+128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/legacy/Stadia+128.png -------------------------------------------------------------------------------- /src/components/styles/ForceCodec.scss: -------------------------------------------------------------------------------- 1 | @use "../../ui/styles/Button.scss"; 2 | @use "../../styles/Grid.scss"; 3 | -------------------------------------------------------------------------------- /src/popup/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/src/popup/src/assets/logo.png -------------------------------------------------------------------------------- /docs/assets/images/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/docs/assets/images/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/docs/assets/images/widgets.png -------------------------------------------------------------------------------- /images/PlayButtonBackground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/images/PlayButtonBackground.png -------------------------------------------------------------------------------- /src/components/styles/ForceResolution.scss: -------------------------------------------------------------------------------- 1 | @use "../../ui/styles/Button.scss"; 2 | @use "../../styles/Grid.scss"; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /docs/assets/images/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mafrans/StadiaPlus/HEAD/docs/assets/images/widgets@2x.png -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Information about contributing to Stadia+ can be found [on the wiki](https://github.com/Mafrans/StadiaPlus/wiki/Contributing). -------------------------------------------------------------------------------- /src/components/styles/Clock.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_clock { 2 | font-size: 2.5rem; 3 | padding: 1rem 1.5rem; 4 | font-weight: 300; 5 | } -------------------------------------------------------------------------------- /src/models/CheckboxInstance.ts: -------------------------------------------------------------------------------- 1 | export interface CheckboxInstance { 2 | pretty: HTMLDivElement; 3 | checkbox: HTMLInputElement; 4 | } 5 | -------------------------------------------------------------------------------- /src/appdata.json: -------------------------------------------------------------------------------- 1 | { 2 | "cache-version": 0.2, 3 | "clear-keys": { 4 | "local": [ 5 | "games" 6 | ], 7 | "sync": [] 8 | } 9 | } -------------------------------------------------------------------------------- /src/models/SwitchStyle.ts: -------------------------------------------------------------------------------- 1 | export class SwitchStyle { 2 | public static DEFAULT = ''; 3 | public static FILL = 'p-fill'; 4 | public static SLIM = 'p-slim'; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/CheckboxStyle.ts: -------------------------------------------------------------------------------- 1 | export class CheckboxStyle { 2 | public static DEFAULT = ''; 3 | public static FILL = 'p-fill'; 4 | public static THICK = 'p-thick'; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/CheckboxShape.ts: -------------------------------------------------------------------------------- 1 | export class CheckboxShape { 2 | public static DEFAULT = ''; 3 | public static CURVED = 'p-curve'; 4 | public static ROUND = 'p-round'; 5 | } 6 | -------------------------------------------------------------------------------- /src/models/AppdataManifest.ts: -------------------------------------------------------------------------------- 1 | export interface AppdataManifest { 2 | 'cache-version': number; 3 | 'clear-keys': { 4 | local: string[]; 5 | sync: string[]; 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/popup/src/assets/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap'); 2 | @import url('https://fonts.googleapis.com/icon?family=Material+Icons'); -------------------------------------------------------------------------------- /images/icons/filter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/UIRowOptions.ts: -------------------------------------------------------------------------------- 1 | import { UIRow } from '../ui/UIRow'; 2 | 3 | export class UIRowOptions { 4 | onCreate?: (row: UIRow) => void; 5 | onDestroy?: (row: UIRow) => void; 6 | onReload?: (row: UIRow) => void; 7 | } 8 | -------------------------------------------------------------------------------- /images/icons/windowed.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/OrderDirection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum containing different order directions 3 | * 4 | * @export the OrderDirection type. 5 | * @enum {number} 6 | */ 7 | 8 | export enum OrderDirection { 9 | ASCENDING, 10 | DESCENDING 11 | } 12 | -------------------------------------------------------------------------------- /src/models/SelectStyle.ts: -------------------------------------------------------------------------------- 1 | export class SelectStyle { 2 | public static SLIMSELECT = ''; 3 | public static SLIMSELECT_LARGE = 'style-slimselect-large'; 4 | public static LIGHT = 'style-light'; 5 | public static DARK = 'style-dark'; 6 | } 7 | -------------------------------------------------------------------------------- /images/icons/windowed_exit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/models/CheckboxColor.ts: -------------------------------------------------------------------------------- 1 | export class CheckboxColor { 2 | public static BLUE = 'p-primary'; 3 | public static GREEN = 'p-success'; 4 | public static YELLOW = 'p-warning'; 5 | public static CYAN = 'p-info'; 6 | public static RED = 'p-danger'; 7 | } 8 | -------------------------------------------------------------------------------- /src/models/CheckboxAnimation.ts: -------------------------------------------------------------------------------- 1 | export class CheckboxAnimation { 2 | public static SMOOTH = 'p-smooth'; 3 | public static JELLY = 'p-jelly'; 4 | public static TADA = 'p-tada'; 5 | public static ROTATE = 'p-rotate'; 6 | public static PULSE = 'p-pulse'; 7 | } 8 | -------------------------------------------------------------------------------- /src/models/SelectOptions.ts: -------------------------------------------------------------------------------- 1 | import { Option } from 'slim-select/dist/data'; 2 | 3 | export interface SelectOptions { 4 | placeholder: string | undefined; 5 | style?: string; 6 | onChange?: (info: Option) => void; 7 | beforeChange?: (info: Option) => void; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/AFLibraryData.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line camelcase 2 | export interface AFLibraryData { 3 | data: [boolean, [string, [string, string, boolean, number]], unknown[]]; 4 | hash: string, 5 | isError: boolean, 6 | key: string, 7 | sideChannel: unknown, 8 | } 9 | -------------------------------------------------------------------------------- /images/icons/network-monitor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "es6", 7 | "target": "es2015", 8 | "jsx": "react", 9 | "allowJs": true, 10 | "moduleResolution": "node", 11 | "strict": true 12 | } 13 | } -------------------------------------------------------------------------------- /src/styles/Typography.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_muted { 2 | opacity: 0.5; 3 | } 4 | 5 | .stadiaplus_icon-inline { 6 | vertical-align: text-bottom; 7 | padding-left: 0.25rem; 8 | padding-right: 0.25rem; 9 | 10 | &:first-child { 11 | padding-left: initial; 12 | } 13 | 14 | &:last-child { 15 | padding-right: initial; 16 | } 17 | } -------------------------------------------------------------------------------- /images/icons/visibility.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Shortcut.ts: -------------------------------------------------------------------------------- 1 | import downloader from './util/downloader'; 2 | 3 | export class Shortcut { 4 | url: string; 5 | name: string; 6 | constructor(url: string, name: string) { 7 | this.url = url; 8 | this.name = name; 9 | } 10 | 11 | save(): void { 12 | downloader.download(``, `${this.name}.htm`, 'text/html'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/popup/src/components/Icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /src/models/Codec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The different kinds of codecs, represented as numbers. 3 | * 4 | * @export the Codec type 5 | * @class Codec 6 | */ 7 | 8 | export class Codec { 9 | /** 10 | * Automatic codec, let Stadia decide on it's own. 11 | */ 12 | static AUTOMATIC = 0; 13 | 14 | /** 15 | * VP9 codec, usually works better than H264 but at the cost of lower quality. 16 | */ 17 | static VP9 = 1; 18 | 19 | /** 20 | * H264 codec, high quality and Mac-OS compatible codec but with latency issues. 21 | */ 22 | static H264 = 2; 23 | } 24 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | chrome.runtime.onInstalled.addListener(() => { 4 | chrome.declarativeContent.onPageChanged.removeRules(undefined, () => { 5 | chrome.declarativeContent.onPageChanged.addRules([{ 6 | conditions: [ 7 | new chrome.declarativeContent.PageStateMatcher({ 8 | pageUrl: { hostEquals: 'stadia.google.com' }, 9 | }), 10 | ], 11 | actions: [ 12 | new chrome.declarativeContent.ShowPageAction(), 13 | ], 14 | }]); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: never; 3 | export default content; 4 | } 5 | 6 | declare module '*.png' { 7 | const content: never; 8 | export default content; 9 | } 10 | 11 | declare module '*.jpg' { 12 | const content: never; 13 | export default content; 14 | } 15 | 16 | declare module '*.css' { 17 | const content: never; 18 | export default content; 19 | } 20 | 21 | declare module '*.scss' { 22 | const content: never; 23 | export default content; 24 | } 25 | 26 | declare module '*.json' { 27 | const value: never; 28 | export default value; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/styles/LookingForGroup.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_lookingforgroup-groups { 2 | background-color: rgba(255,255,255,.06); 3 | padding: .5rem; 4 | margin-top: 1rem; 5 | border-radius: .25rem; 6 | display: none; 7 | 8 | &.visible { 9 | display: block; 10 | } 11 | 12 | >h6 { 13 | display: inline-block; 14 | } 15 | 16 | .refresh { 17 | float: right; 18 | font-size: 30px; 19 | color: #ffffff; 20 | margin: 6px; 21 | cursor: pointer; 22 | } 23 | 24 | .group-list { 25 | margin-top: .5rem; 26 | display: block; 27 | } 28 | } -------------------------------------------------------------------------------- /src/models/Resolution.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The different kinds of resolutions, represented as numbers. 3 | * 4 | * @export the Resolution type 5 | * @class Resolution 6 | */ 7 | 8 | export class Resolution { 9 | /** 10 | * Automatic, let Stadia handle resolutions. 11 | */ 12 | static AUTOMATIC = 0; 13 | 14 | /** 15 | * 4K, or 3840x2160 16 | */ 17 | static UHD_4K = 1; 18 | 19 | /** 20 | * WQHD, or 2560x1440 21 | */ 22 | static WQHD = 2; 23 | 24 | /** 25 | * Full HD, or 1920x1080 26 | */ 27 | static FHD = 3; 28 | 29 | /** 30 | * HD, or 1280x720 31 | */ 32 | static HD = 4; 33 | } 34 | -------------------------------------------------------------------------------- /src/styles/Global.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/icon?family=Material+Icons"); 2 | 3 | .GqLi4d { 4 | filter: brightness(0.9) contrast(1.1); // This is mostly to make sure that the eye icons in the library can be seen. 5 | } 6 | 7 | html body .dSGvzf { 8 | margin: 0 1rem; 9 | } 10 | 11 | html body .CVVXfc { 12 | flex-direction: column; 13 | align-items: initial; 14 | } 15 | 16 | hr { 17 | border: none; 18 | border-bottom: 1px solid rgba(255,255,255,.06); 19 | } 20 | 21 | ::-webkit-scrollbar { 22 | background-color: rgb(70, 72, 77); 23 | } 24 | 25 | ::-webkit-scrollbar-thumb { 26 | background-color: rgb(80, 82, 87) !important; 27 | } -------------------------------------------------------------------------------- /images/icons/visibility_off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Browser.ts: -------------------------------------------------------------------------------- 1 | export class Browser { 2 | private static version: number; 3 | static init(): void { 4 | const versionString = (navigator.appVersion.split(' ')).find((e: string) => e.indexOf('Chrome') !== -1); 5 | 6 | if (versionString === undefined) return; 7 | const version = versionString.split('/')[1].split('.').map((s) => parseInt(s, 10)); 8 | 9 | let accumulator = 0; 10 | for (let i = 0; i < version.length; i += 1) { 11 | accumulator += version[i] * (10 ** ((version.length - i - 1) * 2)); 12 | } 13 | 14 | this.version = accumulator; 15 | } 16 | 17 | static getVersion(): number { 18 | return this.version; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /images/icons/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/styles/Ratings.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_rating { 2 | margin-top: -1rem; 3 | margin-bottom: 1rem; 4 | position: relative; 5 | 6 | &:hover .stadiaplus_rating-tooltip { 7 | opacity: 1; 8 | transform: translateX(-50%) scale(1); 9 | } 10 | 11 | .stadiaplus_rating-tooltip { 12 | font-family: 'Google Sans', sans-serif; 13 | position: absolute; 14 | top: 100%; 15 | left: 50%; 16 | transform: translateX(-50%) scale(.9); 17 | padding: 0.5rem; 18 | background: rgba(0,0,0,0.8); 19 | border-radius: 0.5rem; 20 | color: #ffffff; 21 | font-size: 20px; 22 | opacity: 0; 23 | 24 | transition: opacity 0.3s ease-in-out 0.3s, transform 0.3s ease-in-out 0.3s; 25 | } 26 | } -------------------------------------------------------------------------------- /src/popup/src/components/Spinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 37 | -------------------------------------------------------------------------------- /src/components/styles/NetworkMonitor.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_networkmonitor { 2 | position: absolute; 3 | width: 300px; 4 | top: 0; 5 | left: 0; 6 | z-index: 150; 7 | padding: 1rem; 8 | background-color: rgba(0,0,0,0.4); 9 | 10 | * { 11 | user-select: none; 12 | } 13 | 14 | ul { 15 | list-style-type: none; 16 | padding-inline-start: 0; 17 | margin-block-start: 0; 18 | margin-block-end: 0; 19 | } 20 | 21 | &.editable { 22 | z-index: 300; 23 | cursor: move; 24 | } 25 | } 26 | 27 | .stadiaplus_networkmonitor-tab { 28 | ul { 29 | list-style-type: none; 30 | padding-inline-start: 1rem; 31 | margin-block-start: 0; 32 | margin-block-end: 0; 33 | } 34 | } 35 | 36 | .stadiaplus_networkmonitor-checkbox { 37 | margin: 0.4rem 0; 38 | } -------------------------------------------------------------------------------- /src/ui/styles/Button.scss: -------------------------------------------------------------------------------- 1 | 2 | .stadiaplus_button { 3 | margin-top: 1rem; 4 | box-shadow: none !important; 5 | } 6 | 7 | .stadiaplus_button-small { 8 | padding: 0.5rem 1rem; 9 | background-color: #3C3E43; 10 | color: #ffffff; 11 | margin: 0 0.5rem; 12 | border-radius: 0.25rem; 13 | } 14 | .stadiaplus_ui-btn-wrapper { 15 | margin-bottom: 70%; 16 | } 17 | .stadiaplus_navbutton { 18 | display: inline-flex; 19 | 20 | height: 2.5rem; 21 | width: 2.5rem; 22 | border-radius: 25px; 23 | 24 | align-items: center; 25 | justify-content: center; 26 | margin-right: 5px; 27 | 28 | color: #E8EAED; 29 | cursor: pointer; 30 | 31 | &.active { 32 | color: #ff773d; 33 | background-color: rgba(255, 255, 255, 0.06); 34 | } 35 | 36 | &:hover { 37 | background-color: rgba(255, 255, 255, 0.06); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/styles/UITab.scss: -------------------------------------------------------------------------------- 1 | @use '../../styles/Typography.scss'; 2 | 3 | .stadiaplus_ui-component { 4 | /* 5 | * Must remove 2 x padding or it doesnt work 6 | */ 7 | width: calc(100% - 2rem); 8 | height: calc(100% - 2rem); 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | transform: translateX(100%); 13 | padding: 1rem; 14 | background-color: #2d2e30; 15 | transition: transform 0.15s ease-in-out; 16 | z-index: 999; 17 | 18 | &.open { 19 | transform: translateX(0); 20 | } 21 | 22 | header { 23 | display: flex; 24 | align-items: center; 25 | 26 | .CwCxFd { 27 | font-size: 22px; 28 | } 29 | } 30 | } 31 | 32 | .stadiaplus_ui-btn-container { 33 | margin-top: -16px; 34 | 35 | &.E0Zk9b { 36 | justify-content: space-between; 37 | } 38 | } 39 | 40 | .stadiaplus_ui-button { 41 | width: 130.677px; 42 | } -------------------------------------------------------------------------------- /images/icons/stadiaplus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Stadia+ has been archived 3 | As I no longer have the time or motivation to continue work on this project, it has been archived. Please consider [Stadia Enhanced](https://github.com/ChristopherKlay/StadiaEnhanced) as an alternative. 4 | 5 | ![Banner Image](https://i.imgur.com/Vgge8yv.png) 6 | 7 | # Stadia+ 8 | 9 | Stadia+ is an addon for Google's [Stadia](https://stadia.google.com) gaming platform. It includes a lot of useful features and additions that help with everything from solving network issues to managing your game library. Stadia+ is developed independently, licensed under GNU GPL v3 and has no connection to Google, Alphabet or any other Google product or subsidiary. 10 | 11 | If you're new to Stadia+, make sure to read through our [Getting Started](https://github.com/Mafrans/StadiaPlus/wiki/Getting-Started) guide. 12 | 13 | ## Installation 14 | [![Available in the Chrome Web Store](https://developer.chrome.com/webstore/images/ChromeWebStore_Badge_v2_206x58.png)](https://chrome.google.com/webstore/detail/bbhmnnecicphphjamhdefpagipoegijd) 15 | -------------------------------------------------------------------------------- /src/models/CaptureItem.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../Logger'; 2 | 3 | export class CaptureItem { 4 | public id: string | null = null; 5 | public ageString: string | null = null; 6 | public thumbnail: string | null = null; 7 | public isVideo = false; 8 | 9 | constructor(element: HTMLElement) { 10 | if (element.childNodes[3].firstChild === null 11 | || element.childNodes[3].firstChild.firstChild === null) { 12 | Logger.error('A capture couldn\'t be created.'); 13 | return; 14 | } 15 | 16 | this.id = element.getAttribute('data-capture-id'); 17 | this.ageString = element.childNodes[3].firstChild.firstChild.textContent; 18 | this.thumbnail = (element.childNodes[1] as HTMLElement).getAttribute('data-thumbnail'); 19 | 20 | this.isVideo = element.querySelector('.MUpfsb') != null; 21 | } 22 | 23 | open(): void { 24 | if (this.id === null) return; 25 | (document.querySelector(`.MykDQe[data-capture-id="${this.id}"]`) as HTMLElement).click(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /images/legacy/Stadia_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /src/popup/src/components/SelectBox.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/styles/StadiaPlusDBHook.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_web-scraper-popup { 2 | width: 300px; 3 | height: 90px; 4 | position: absolute; 5 | top: 0; 6 | border-radius: .5rem; 7 | align-items: center; 8 | justify-content: middle; 9 | left: 0; 10 | margin: 1rem; 11 | background-color: #202124; 12 | z-index: 1; 13 | display: flex; 14 | box-shadow: 0 0.125rem 0.75rem rgba(0,0,0,0.32), 0 0.0625rem 0.375rem rgba(0,0,0,0.18); 15 | 16 | .stadiaplus_web-scraper-icon { 17 | font-family: 'Material Icons Extended'; 18 | font-size: 48px; 19 | padding: 1rem; 20 | 21 | &.loading { 22 | animation: spinning 1s linear 0s infinite; 23 | 24 | @keyframes spinning { 25 | from { 26 | transform: rotate(0deg); 27 | } 28 | to { 29 | transform: rotate(360deg); 30 | } 31 | } 32 | } 33 | } 34 | 35 | .stadiaplus_web-scraper-title { 36 | font-size: 1rem; 37 | font-weight: 500; 38 | margin-bottom: .25rem; 39 | } 40 | 41 | .stadiaplus_web-scraper-body { 42 | font-size: .75rem; 43 | } 44 | } -------------------------------------------------------------------------------- /src/popup/src/assets/Google G.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ui/styles/Modal.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_modal { 2 | position: fixed; 3 | background: rgba(0, 0, 0, 0.4); 4 | z-index: 100; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | padding: 16px; 10 | pointer-events: none; 11 | transition: opacity 0.2s ease; 12 | opacity: 0; 13 | 14 | .stadiaplus_modal-wrapper { 15 | position: fixed; 16 | max-width: 33.333%; 17 | min-width: 20%; 18 | padding: 1rem; 19 | border-radius: 0.5rem; 20 | background: #303236; 21 | left: 50%; 22 | top: 50%; 23 | transform: translate(-50%, -50%) scale(0.8); 24 | transition: transform 0.2s ease; 25 | } 26 | 27 | .stadiaplus_modal-close { 28 | float: right; 29 | padding: 8px; 30 | border-radius: 50%; 31 | color: white; 32 | font-size: 24px; 33 | 34 | &:hover { 35 | background-color: rgba(255, 255, 255, 0.05); 36 | cursor: pointer; 37 | } 38 | } 39 | 40 | &.active { 41 | opacity: 1; 42 | pointer-events: initial; 43 | 44 | .stadiaplus_modal-wrapper { 45 | transform: translate(-50%, -50%) scale(1.0); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/util/EventTracker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JS Event tracker, doesn't work unless we use a bunch of weird 3 | * types and unsafe stuff. 4 | */ 5 | 6 | /* eslint-disable no-undef */ 7 | /* eslint-disable @typescript-eslint/unbound-method */ 8 | export class EventTracker { 9 | target?: EventTarget; 10 | 11 | private original: ( 12 | type: string, 13 | listener: EventListener | EventListenerObject | null, 14 | options?: boolean | AddEventListenerOptions | undefined 15 | ) => void; 16 | 17 | constructor( 18 | target: EventTarget, 19 | _listener: ( 20 | type: string, 21 | listener?: EventListenerOrEventListenerObject | null, 22 | options?: AddEventListenerOptions | boolean) => () => void, 23 | ) { 24 | this.original = target.addEventListener; 25 | this.target = target; 26 | 27 | const { original } = this; 28 | target.addEventListener = (type, listener, options?) => { 29 | _listener(type, listener, options); 30 | original(type, listener, options); 31 | }; 32 | } 33 | 34 | remove(): void { 35 | if (this.target === undefined) return; 36 | this.target.addEventListener = this.original; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/styles/Snackbar.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_snackbar { 2 | width: 400px; 3 | display: flex; 4 | justify-content: space-between; 5 | align-items: center; 6 | background-color: #333; 7 | border-radius: 4px; 8 | z-index: 999; // Always show on top. 9 | 10 | -webkit-box-shadow: 0 3px 5px -1px rgba(0,0,0,.2), 0 6px 10px 0 rgba(0,0,0,.14), 0 1px 18px 0 rgba(0,0,0,.12); 11 | box-shadow: 0 3px 5px -1px rgba(0,0,0,.2), 0 6px 10px 0 rgba(0,0,0,.14), 0 1px 18px 0 rgba(0,0,0,.12); 12 | 13 | position: fixed; 14 | bottom: 8px; 15 | left: calc(50% - 200px); // Subtract half-width to center 16 | 17 | transform: scale(0.5) translateY(16px); 18 | opacity: 0; 19 | transition: transform 0.15s cubic-bezier(0,0,.2,1), opacity 0.15s cubic-bezier(0,0,.2,1); 20 | 21 | &.active { 22 | transform: scale(1) translateY(0px); 23 | opacity: 1; 24 | } 25 | } 26 | 27 | .stadiaplus_snackbar-label { 28 | font-size: 16px; 29 | padding: 16px; 30 | } 31 | 32 | .stadiaplus_snackbar-close { 33 | padding: 8px; 34 | margin: 8px; 35 | border-radius: 50%; 36 | color: white; 37 | font-size: 20px; 38 | 39 | &:hover { 40 | background-color: rgba(255, 255, 255, 0.05); 41 | cursor: pointer; 42 | } 43 | } -------------------------------------------------------------------------------- /src/popup/src/components/Button.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | 20 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | const prefix = '[Stadia+]'; 4 | 5 | class Logger { 6 | info(...obj: any[]) { 7 | console.log(`${prefix} %c📃 ${obj.join(' ')}`, 'color: black'); 8 | } 9 | 10 | warning(...obj: any[]) { 11 | console.log(`${prefix} %c😟 ${obj.join(' ')}`, 'color: orange'); 12 | } 13 | 14 | error(...obj: any[]) { 15 | console.log(`${prefix} %c❌ ${obj.join(' ')}`, 'color: red; font-weight: 700'); 16 | } 17 | 18 | component(...obj: any[]) { 19 | console.log(`${prefix} %c🧩 ${obj.join(' ')}`, 'color: darkgreen'); 20 | } 21 | 22 | /** 23 | * Dubiously created by Adrian Cooney 24 | * @author http://adriancooney.github.io 25 | */ 26 | image(url: string, width: number, height: number) { 27 | const getBox = (w: number, h: number) => ({ 28 | string: '+', 29 | style: `font-size: 1px; padding: ${Math.floor(h / 2)}px ${Math.floor(w / 2)}px; line-height: 0;`, 30 | }); 31 | 32 | const dim = getBox(width, height); 33 | console.log(`%c${dim.string}`, `${dim.style}background: url(${url}); background-size: ${width}px ${height}px; color: transparent;`); 34 | } 35 | } 36 | 37 | export default new Logger(); 38 | -------------------------------------------------------------------------------- /src/popup/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import App from './App.vue'; 4 | import MainPage from './MainPage.vue'; 5 | import UserPage from './UserPage.vue'; 6 | import WipeDataPage from './WipeDataPage.vue'; 7 | import SettingsPage from './SettingsPage.vue'; 8 | import DeveloperPage from './DeveloperPage.vue'; 9 | import ComponentPage from './ComponentPage.vue'; 10 | import { Language } from '../../Language'; 11 | 12 | Vue.config.productionTip = false; 13 | Vue.use(VueRouter); 14 | 15 | const routes = [ 16 | { path: '/', component: MainPage }, 17 | { path: '/user/', component: UserPage }, 18 | { path: '/user/wipedata', component: WipeDataPage }, 19 | { path: '/settings/', component: SettingsPage }, 20 | { path: '/settings/developer', component: DeveloperPage }, 21 | { path: '/settings/components', component: ComponentPage }, 22 | ]; 23 | 24 | const router = new VueRouter({ 25 | base: 'dist/popup.html', // taken from manifest.json 26 | // mode: 'history', 27 | routes, // short for `routes: routes` 28 | }); 29 | 30 | // Always load languages first 31 | Language.init(); 32 | Language.load().then(() => { 33 | console.log("new Vue") 34 | 35 | const app = document.createElement('div'); 36 | document.body.appendChild(app); 37 | 38 | new Vue({ 39 | router, 40 | render: (h) => h(App), 41 | }).$mount(app); 42 | }); 43 | -------------------------------------------------------------------------------- /src/ui/Snackbar.ts: -------------------------------------------------------------------------------- 1 | import './styles/Snackbar.scss'; 2 | 3 | export class Snackbar { 4 | static instance: Snackbar; 5 | 6 | element: Element; 7 | label: Element; 8 | closeButton: Element; 9 | 10 | constructor() { 11 | this.element = document.createElement('div'); 12 | this.element.classList.add('stadiaplus_snackbar'); 13 | 14 | this.label = document.createElement('div'); 15 | this.label.classList.add('stadiaplus_snackbar-label'); 16 | 17 | this.closeButton = document.createElement('i'); 18 | this.closeButton.innerHTML = 'close'; 19 | this.closeButton.classList.add('material-icons', 'stadiaplus_snackbar-close'); 20 | 21 | this.closeButton.addEventListener('click', () => { 22 | this.element.classList.remove('active'); 23 | }); 24 | } 25 | 26 | create(): void { 27 | document.body.appendChild(this.element); 28 | this.element.appendChild(this.label); 29 | this.element.appendChild(this.closeButton); 30 | } 31 | 32 | static init(): void { 33 | this.instance = new Snackbar(); 34 | this.instance.create(); 35 | } 36 | 37 | static activate(label: string): void { 38 | const { instance } = this; 39 | 40 | instance.label.innerHTML = label; 41 | instance.element.classList.add('active'); 42 | 43 | window.setTimeout(() => { 44 | instance.element.classList.remove('active'); 45 | }, 5000); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/popup/src/components/PageHeader.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 34 | 35 | -------------------------------------------------------------------------------- /src/popup/src/DeveloperPage.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 50 | 51 | 56 | -------------------------------------------------------------------------------- /src/ui/UIRow.ts: -------------------------------------------------------------------------------- 1 | import { UIComponent } from './UIComponent'; 2 | import { UIRowOptions } from '../models/UIRowOptions'; 3 | 4 | export class UIRow { 5 | title: string; 6 | content: string; 7 | id: string; 8 | options: UIRowOptions = {}; 9 | element: Element; 10 | 11 | constructor(title: string, content: string, id: string, options?: UIRowOptions) { 12 | this.title = title; 13 | this.content = content; 14 | if (options !== undefined) { 15 | this.options = options; 16 | } 17 | this.id = id; 18 | 19 | this.element = document.createElement('div'); 20 | this.element.id = this.id; 21 | this.element.innerHTML = ` 22 |

${this.title}

23 |
24 | ${this.content} 25 |
26 | `; 27 | this.element.classList.add('stadiaplus_ui-row'); 28 | } 29 | 30 | exists(): HTMLElement | null { 31 | return document.getElementById(this.id); 32 | } 33 | 34 | destroy(): void { 35 | if (this.options.onDestroy !== undefined) { 36 | this.options.onDestroy(this); 37 | } 38 | 39 | this.element.remove(); 40 | } 41 | 42 | reload(): void { 43 | if (this.options.onReload !== undefined) { 44 | this.options.onReload(this); 45 | } 46 | } 47 | 48 | append(component: UIComponent, useHr = false): void { 49 | if (useHr) { 50 | component.element?.appendChild(document.createElement('hr')); 51 | } 52 | 53 | component.element?.appendChild(this.element); 54 | 55 | if (this.options.onCreate !== undefined) { 56 | this.options.onCreate(this); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/popup/src/components/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 46 | 47 | -------------------------------------------------------------------------------- /src/ui/UIButtonContainer.ts: -------------------------------------------------------------------------------- 1 | import { UIButton } from './UIButton'; 2 | 3 | export class UIButtonContainer { 4 | buttons: UIButton[] = []; 5 | container: Element | null; 6 | element: Element; 7 | id: string; 8 | wrapper: Element; 9 | 10 | constructor() { 11 | this.id = `button-container-${Math.floor(Math.random() * 9999)}`; 12 | this.container = document.querySelector('.TZ0BN'); 13 | 14 | this.wrapper = document.createElement('div'); 15 | this.wrapper.id = this.id; 16 | this.wrapper.classList.add('ZgUMo', 'stadiaplus_ui-btn-wrapper'); 17 | 18 | this.element = document.createElement('div'); 19 | this.element.classList.add('E0Zk9b', 'stadiaplus_ui-btn-container'); 20 | } 21 | 22 | exists(): boolean { 23 | return document.getElementById(this.id) !== null; 24 | } 25 | 26 | create(callback?: () => void): void { 27 | if (!this.exists()) { 28 | this.container = document.querySelector('.TZ0BN'); // Requery in case the container was deleted 29 | this.wrapper.appendChild(this.element); 30 | 31 | if (this.container !== null) { 32 | this.container.appendChild(this.wrapper); 33 | } 34 | } 35 | 36 | this.buttons.forEach((button) => { 37 | if (!button.exists()) { 38 | this.element.appendChild(button.element); 39 | } 40 | }); 41 | 42 | if (callback) { callback(); } 43 | } 44 | 45 | addButton(button: UIButton): void { 46 | if (this.buttons.indexOf(button) === -1) { 47 | this.buttons.push(button); 48 | } 49 | } 50 | 51 | removeButton(button: UIButton): void { 52 | this.buttons = this.buttons.filter((b) => b.id !== button.id); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/popup/src/components/PageButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 32 | 33 | 79 | -------------------------------------------------------------------------------- /src/popup/src/WipeDataPage.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | 50 | 63 | -------------------------------------------------------------------------------- /src/WebDatabase.ts: -------------------------------------------------------------------------------- 1 | import Logger from './Logger'; 2 | 3 | export class WebDatabase { 4 | url: string; 5 | connected = false; 6 | connection: unknown; 7 | 8 | constructor(url: string) { 9 | this.url = url; 10 | } 11 | 12 | connect(): Promise { 13 | return new Promise((resolve, reject) => { 14 | if (this.connected) { 15 | Logger.error('Error: Already connected to the database.'); 16 | return; 17 | } 18 | 19 | const xhr = new XMLHttpRequest(); 20 | xhr.open('GET', this.url, true); 21 | xhr.onload = () => { 22 | if (xhr.readyState === 4) { 23 | if (xhr.status === 200) { 24 | this.connected = true; 25 | this.connection = JSON.parse(xhr.responseText); 26 | resolve(this.connection); 27 | } else { 28 | this.connected = false; 29 | reject(); 30 | Logger.error('Error when connecting to database:', xhr.statusText); 31 | } 32 | } 33 | }; 34 | 35 | xhr.onerror = () => { 36 | this.connected = false; 37 | reject(); 38 | Logger.error('Error when connecting to database:', xhr.statusText); 39 | }; 40 | 41 | xhr.send(null); 42 | }); 43 | } 44 | 45 | getConnection(): unknown { 46 | if (!this.connected) { 47 | Logger.error('Error: Not connected to the database'); 48 | return null; 49 | } 50 | return this.connection; 51 | } 52 | 53 | disconnect(): void { 54 | this.connection = null; 55 | this.connected = false; 56 | } 57 | 58 | async reconnect(): Promise { 59 | this.disconnect(); 60 | return this.connect(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/popup/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 39 | 40 | 41 | 57 | -------------------------------------------------------------------------------- /src/ui/Modal.ts: -------------------------------------------------------------------------------- 1 | import { ElGen } from '../util/ElGen'; 2 | import './styles/Modal.scss'; 3 | 4 | export class Modal { 5 | static instance: Modal; 6 | 7 | element: Element; 8 | wrapper: Element; 9 | content: Element; 10 | closeButton: Element; 11 | 12 | constructor() { 13 | this.element = document.createElement('div'); 14 | this.element.classList.add('stadiaplus_modal'); 15 | 16 | this.wrapper = document.createElement('div'); 17 | this.wrapper.classList.add('stadiaplus_modal-wrapper'); 18 | 19 | this.content = document.createElement('div'); 20 | this.content.classList.add('stadiaplus_modal-content'); 21 | 22 | this.closeButton = document.createElement('i'); 23 | this.closeButton.innerHTML = 'close'; 24 | this.closeButton.classList.add('material-icons', 'stadiaplus_modal-close'); 25 | 26 | this.closeButton.addEventListener('click', () => { 27 | this.element.classList.remove('active'); 28 | }); 29 | } 30 | 31 | create(): void { 32 | document.body.appendChild(this.element); 33 | this.element.appendChild(this.wrapper); 34 | this.wrapper.appendChild(this.closeButton); 35 | this.wrapper.appendChild(this.content); 36 | 37 | this.element.addEventListener('click', () => this.close()); 38 | this.wrapper.addEventListener('click', (event) => event.stopPropagation()); 39 | } 40 | 41 | close(): void { 42 | this.element.classList.remove('active'); 43 | } 44 | 45 | static activate(content: string | ElGen): void { 46 | if (content instanceof ElGen) { 47 | content.appendTo(this.instance.content); 48 | } else { 49 | this.instance.content.innerHTML = content; 50 | } 51 | 52 | this.instance.element.classList.add('active'); 53 | } 54 | 55 | static close(): void { 56 | this.instance.close(); 57 | } 58 | 59 | static init(): void { 60 | this.instance = new Modal(); 61 | this.instance.create(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/util/Util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import Logger from '../Logger'; 3 | 4 | class Util { 5 | /** 6 | * Stadia's menu bar element, used to know when the player has opened the menu. 7 | */ 8 | menuElement!: HTMLElement | null; 9 | renderer?: HTMLElement; 10 | 11 | load() { 12 | this.menuElement = document.querySelector('.X1asv'); 13 | } 14 | 15 | isMenuOpen() { 16 | if (this.menuElement === null) { 17 | Logger.error('Could not find the menu element'); 18 | return false; 19 | } 20 | 21 | return this.menuElement.style.opacity !== '0'; 22 | } 23 | 24 | isInGame() { 25 | return window.location.pathname.indexOf('player') !== -1; 26 | } 27 | 28 | isInHome() { 29 | return window.location.pathname.indexOf('home') !== -1; 30 | } 31 | 32 | isInStore() { 33 | return window.location.pathname.indexOf('store') !== -1 && !this.isInStoreDetail(); 34 | } 35 | 36 | isInStoreDetail() { 37 | return window.location.pathname.indexOf('store/details') !== -1; 38 | } 39 | 40 | desandbox(javascript: string) { 41 | const script = document.createElement('script'); 42 | script.innerHTML = javascript; 43 | document.body.appendChild(script); 44 | script.remove(); 45 | } 46 | 47 | shuffle(array: T[]) { 48 | for (let i = array.length - 1; i > 0; i -= 1) { 49 | const j = Math.floor(Math.random() * i); 50 | const temp = array[i]; 51 | array[i] = array[j]; 52 | array[j] = temp; 53 | } 54 | return array; 55 | } 56 | 57 | updateRenderer(): void { 58 | const renderers = document.querySelectorAll('.lhsE4e>c-wiz'); 59 | let newRenderer = renderers.item(0) as HTMLElement; 60 | if (renderers.length > 1) { 61 | newRenderer = Array.from(renderers).find((renderer: Element) => (renderer as HTMLElement).style.opacity === '1') as HTMLElement; 62 | } 63 | 64 | if (newRenderer != null) this.renderer = newRenderer; 65 | } 66 | } 67 | export default new Util(); 68 | -------------------------------------------------------------------------------- /src/ui/Switch.ts: -------------------------------------------------------------------------------- 1 | import '../../node_modules/pretty-checkbox/src/pretty-checkbox.scss'; 2 | import { SwitchStyle } from '../models/SwitchStyle'; 3 | 4 | export class Switch { 5 | private label: string; 6 | private style: string = SwitchStyle.DEFAULT; 7 | private color?: string; 8 | private disabled = false; 9 | private bigger = false; 10 | 11 | constructor(label: string) { 12 | this.label = label; 13 | } 14 | 15 | setStyle(style: string): Switch { 16 | this.style = style; 17 | return this; 18 | } 19 | 20 | setColor(color: string): Switch { 21 | this.color = color; 22 | return this; 23 | } 24 | 25 | setDisabled(disabled: boolean): Switch { 26 | this.disabled = disabled; 27 | return this; 28 | } 29 | 30 | setBigger(bigger: boolean): Switch { 31 | this.bigger = bigger; 32 | return this; 33 | } 34 | 35 | build(): { pretty: HTMLElement, checkbox: HTMLInputElement } { 36 | // Create element 37 | const element = document.createElement('div'); 38 | 39 | // Add main classes 40 | element.classList.add('pretty', 'p-switch'); 41 | 42 | // If style is not default, add style 43 | if (this.style !== undefined) { 44 | element.classList.add(this.style); 45 | } 46 | 47 | // Set bigger 48 | if (this.bigger) { 49 | element.classList.add('p-bigger'); 50 | } 51 | 52 | // Add checkbox input 53 | const checkbox = document.createElement('input'); 54 | checkbox.type = 'checkbox'; 55 | checkbox.disabled = this.disabled; 56 | element.appendChild(checkbox); 57 | 58 | // Add state div 59 | const state = document.createElement('div'); 60 | state.classList.add('state'); 61 | 62 | // If colored, add color 63 | if (this.color !== undefined) { 64 | state.classList.add(this.color); 65 | } 66 | 67 | // Add label 68 | const label = document.createElement('label'); 69 | label.innerHTML = this.label; 70 | state.appendChild(label); 71 | 72 | element.appendChild(state); 73 | 74 | return { pretty: element, checkbox }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { Language } from './Language'; 3 | import Util from './util/Util'; 4 | 5 | /** 6 | * A generic component of Stadia+ 7 | * 8 | * @export the Component type. 9 | * @class Component 10 | */ 11 | export class Component { 12 | /** 13 | * The Component's name. 14 | */ 15 | name = 'My Component'; 16 | tag = 'component'; 17 | 18 | /** 19 | * The Component's unique ID, automatically generated on load. 20 | */ 21 | id = 'undefined'; 22 | 23 | /** 24 | * A boolean keeping track of whether the Component should receive events or not. 25 | */ 26 | active = false; 27 | 28 | enabled = false; 29 | renderer?: HTMLElement = Util.renderer; 30 | 31 | /** 32 | * This method is called whenever the component should start loading. 33 | */ 34 | load(): void { 35 | this.name = Language.get(`${this.tag}.name`); 36 | this.id = `stadiaplus_${Math.floor(Math.random() * 999999)}`; 37 | this.updateRenderer(); 38 | this.onStart(); 39 | } 40 | 41 | updateRenderer(): void { 42 | Util.updateRenderer(); 43 | this.renderer = Util.renderer; 44 | } 45 | 46 | /** 47 | * Returns whether this Component has an element in the current renderer 48 | * 49 | * @returns {boolean} 50 | */ 51 | exists(): boolean { 52 | if (Util.renderer == null || Util.renderer.style.opacity === '0') return false; 53 | return Util.renderer.querySelector(`#${this.id}`) !== null; 54 | } 55 | 56 | /** 57 | * Returns whether this Component has an element anywhere in the DOM 58 | * 59 | * @returns {boolean} 60 | */ 61 | existsAnywhere(): boolean { 62 | return document.querySelector(`#${this.id}`) !== null; 63 | } 64 | 65 | /** 66 | * This method is called whenever the component is unloading. 67 | */ 68 | unload(): void { 69 | this.onStop(); 70 | } 71 | 72 | /** 73 | * This method is called when the Component should start. 74 | */ 75 | onStart(): void {} 76 | 77 | /** 78 | * This method is called when the Component should stop. 79 | */ 80 | onStop(): void {} 81 | 82 | /** 83 | * This method is called once every second. 84 | */ 85 | onUpdate(): void {} 86 | } 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | bin/ 4 | 5 | 6 | # Created by https://www.gitignore.io/api/node 7 | # Edit at https://www.gitignore.io/?templates=node 8 | 9 | ### Node ### 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # TypeScript v1 declaration files 54 | typings/ 55 | 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | 59 | # Optional npm cache directory 60 | .npm 61 | 62 | # Optional eslint cache 63 | .eslintcache 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # next.js build output 82 | .next 83 | 84 | # nuxt.js build output 85 | .nuxt 86 | 87 | # Uncomment the public line if your project uses Gatsby 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 90 | # public 91 | 92 | # Storybook build outputs 93 | .out 94 | .storybook-out 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # Temporary folders 109 | tmp/ 110 | temp/ 111 | 112 | # End of https://www.gitignore.io/api/node 113 | 114 | dist -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stadiaplus", 3 | "version": "2.0.0", 4 | "description": "A Chrome extension extending the features of Google's Stadia gaming platform.", 5 | "private": true, 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack --env.production", 9 | "build:dev": "webpack --env.development", 10 | "docs": "npx typedoc" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Mafrans/StadiaPlus.git" 15 | }, 16 | "keywords": [ 17 | "stadia", 18 | "stadia+", 19 | "stadiaplus", 20 | "chrome", 21 | "extension", 22 | "google", 23 | "gaming", 24 | "addon" 25 | ], 26 | "author": "Malte Klüft", 27 | "license": "GPL-3.0", 28 | "bugs": { 29 | "url": "https://github.com/Mafrans/StadiaPlus/issues" 30 | }, 31 | "homepage": "https://github.com/Mafrans/StadiaPlus#readme", 32 | "devDependencies": { 33 | "@types/chrome": "0.0.126", 34 | "@types/es6-promise": "^3.3.0", 35 | "@types/webrtc": "0.0.26", 36 | "@typescript-eslint/parser": "^4.6.1", 37 | "eslint-config-airbnb-base": "^14.2.1", 38 | "eslint-plugin-import": "^2.22.1", 39 | "eslint-plugin-vue": "^7.1.0", 40 | "file-loader": "^5.1.0", 41 | "html-webpack-plugin": "^4.5.1", 42 | "sass": "^1.26.3", 43 | "sass-loader": "^8.0.2", 44 | "ts-loader": "^6.2.1", 45 | "typedoc": "^0.16.11", 46 | "typedoc-webpack-plugin": "^1.1.4", 47 | "typescript": "^3.8.3", 48 | "webpack": "^4.42.0", 49 | "webpack-cli": "^3.3.11" 50 | }, 51 | "dependencies": { 52 | "@typescript-eslint/eslint-plugin": "^4.6.1", 53 | "axios": "^0.21.1", 54 | "bootstrap-vue": "^2.11.0", 55 | "css-loader": "^3.4.2", 56 | "deepmerge": "^4.2.2", 57 | "eslint": "^7.13.0", 58 | "fibers": "^4.0.2", 59 | "material-design-icons": "^3.0.1", 60 | "pretty-checkbox": "^3.0.3", 61 | "raw-loader": "^4.0.0", 62 | "slim-select": "^1.25.0", 63 | "style-loader": "^1.1.3", 64 | "vue": "^2.6.11", 65 | "vue-loader": "^15.9.1", 66 | "vue-router": "^3.1.6", 67 | "vue-template-compiler": "^2.6.11" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Clock.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../Component'; 2 | import Logger from '../Logger'; 3 | import Util from '../util/Util'; 4 | import './styles/Clock.scss'; 5 | import { Language } from '../Language'; 6 | 7 | /** 8 | * A simple clock displayed in the Stadia Menu. 9 | * 10 | * @export the Clock type. 11 | * @class Clock 12 | * @extends {Component} 13 | */ 14 | export class Clock extends Component { 15 | /** 16 | * The component tag, used in language files. 17 | */ 18 | tag = 'clock'; 19 | 20 | /** 21 | * The clock element. 22 | */ 23 | element!: HTMLElement; 24 | 25 | constructor() { 26 | super(); 27 | 28 | this.createElement(); 29 | } 30 | 31 | /** 32 | * Creates a simple , adds the right classes, and stores it in [@link #element] 33 | * 34 | * @memberof Clock 35 | */ 36 | createElement(): void { 37 | this.element = document.createElement('span'); 38 | this.element.classList.add('stadiaplus_clock'); 39 | } 40 | 41 | /** 42 | * Called on startup, initializes important variables. 43 | * 44 | * @memberof Clock 45 | */ 46 | onStart(): void { 47 | this.active = true; 48 | this.element.id = this.id; 49 | 50 | Logger.component(Language.get('component.enabled', { name: this.name })); 51 | } 52 | 53 | /** 54 | * Called on stop, makes sure to dispose of elements and variables. 55 | * 56 | * @memberof Clock 57 | */ 58 | onStop(): void { 59 | this.active = false; 60 | this.element.remove(); 61 | Logger.component(Language.get('component.disabled', { name: this.name })); 62 | } 63 | 64 | /** 65 | * Called every second, updates the element to match the clock. 66 | * 67 | * @memberof Clock 68 | */ 69 | onUpdate(): void { 70 | // Only update the clock when it's visible 71 | if (Util.isMenuOpen()) { 72 | if (!this.existsAnywhere()) { 73 | const container = document.querySelector('.hxhAyf'); 74 | if (container != null) { 75 | container.prepend(this.element); 76 | } 77 | } 78 | 79 | const time = new Date().toLocaleTimeString(); 80 | window.requestAnimationFrame(() => { 81 | this.element.innerHTML = time; 82 | }); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ui/NavButton.ts: -------------------------------------------------------------------------------- 1 | import Logger from '../Logger'; 2 | import { $el, ElGen } from '../util/ElGen'; 3 | import { NavPosition } from '../models/NavPosition'; 4 | import './styles/Button.scss'; 5 | 6 | export class NavButton { 7 | private id: string; 8 | public icon: string | undefined; 9 | public text: string | undefined; 10 | public element: ElGen; 11 | private active = false; 12 | private position: NavPosition = NavPosition.LEFT; 13 | 14 | constructor(icon?: string, text?: string, position?: NavPosition) { 15 | this.icon = icon; 16 | this.text = text; 17 | if (position != null) { 18 | this.position = position; 19 | } 20 | 21 | this.id = `stadiaplus_${Math.floor(Math.random() * 999999)}`; 22 | 23 | this.element = $el('div').id(this.id).class({ stadiaplus_navbutton: true }); 24 | 25 | if (this.icon != null) { 26 | this.element.child( 27 | $el('i') 28 | .class({ 'material-icons-extended': true }) 29 | .text(this.icon), 30 | ); 31 | } 32 | 33 | if (this.text != null) { 34 | this.element.child($el('span').text(this.text)); 35 | } 36 | } 37 | 38 | setActive(value: boolean): void { 39 | this.active = value; 40 | this.element.class({ active: value }); 41 | } 42 | 43 | getActive(): boolean { 44 | return this.active; 45 | } 46 | 47 | onClick(event: (_event: Event) => void): void { 48 | this.element.event({ click: event }); 49 | } 50 | 51 | create(): void { 52 | const navbar = document.querySelector('.w5qDee'); 53 | if (navbar === null) { 54 | Logger.error('The navbar was not found, please report this to the developer of Stadia+'); 55 | return; 56 | } 57 | 58 | if (navbar.querySelector(`#${this.id}`) != null) return; 59 | 60 | switch (this.position) { 61 | case NavPosition.LEFT: 62 | this.element.appendTo(document.querySelector('.tGNEjf>.ZECEje') as Node); 63 | break; 64 | case NavPosition.RIGHT: 65 | this.element.prependTo(document.querySelector('.QBnfOe>.WpnpPe') as Element); 66 | break; 67 | default: break; 68 | } 69 | } 70 | 71 | destroy(): void { 72 | this.element.element.remove(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ui/UIComponent.ts: -------------------------------------------------------------------------------- 1 | export class UIComponent { 2 | id: string; 3 | html: string; 4 | element: Element | null; 5 | open = false; 6 | openListeners: (() => void)[] = []; 7 | closeListeners: (() => void)[] = []; 8 | 9 | constructor(title: string, content: string, id: string) { 10 | this.id = id; 11 | this.html = ` 12 |
13 |
14 | 15 |
16 | 17 |
${title}
18 |
19 |
20 | 21 |
22 | ${content} 23 |
24 | `; 25 | 26 | this.element = document.createElement('div'); 27 | this.element.id = this.id; 28 | this.element.classList.add('stadiaplus_ui-component'); 29 | } 30 | 31 | create(): void { 32 | const container = document.querySelector('.hxhAyf'); 33 | if (container === null || this.element === null) return; 34 | 35 | this.element.innerHTML = this.html; 36 | container.appendChild(this.element); 37 | 38 | // ReQuery element since outerHTML breaks it. 39 | this.element = document.getElementById(this.id); 40 | 41 | const backBtn = document.querySelector( 42 | `#${this.id} > header > .rkvT7c`, 43 | ); 44 | 45 | if (backBtn !== null) { 46 | backBtn.addEventListener('click', () => { 47 | this.closeTab(); 48 | }); 49 | } 50 | } 51 | 52 | openTab(): void { 53 | if (this.element === null) return; 54 | 55 | this.element.classList.add('open'); 56 | this.open = true; 57 | 58 | this.openListeners.forEach((c) => c()); 59 | } 60 | 61 | closeTab(): void { 62 | if (this.element === null) return; 63 | 64 | this.element.classList.remove('open'); 65 | this.open = false; 66 | 67 | this.closeListeners.forEach((c) => c()); 68 | } 69 | 70 | onOpen(callback?:() => void): void { 71 | if (callback) { this.openListeners.push(callback); } 72 | } 73 | 74 | onClose(callback?:() => void): void { 75 | if (callback) { this.closeListeners.push(callback); } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /images/Stadia+.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/ComponentLoader.ts: -------------------------------------------------------------------------------- 1 | import { Component } from './Component'; 2 | import { SyncStorage } from './Storage'; 3 | import Logger from './Logger'; 4 | 5 | /** 6 | * A utility class responsible for loading [[Component|Components]] and delivering events. 7 | * 8 | * @export the ComponentLoader type. 9 | * @class ComponentLoader 10 | */ 11 | export class ComponentLoader { 12 | /** 13 | * A list of all registered components. 14 | */ 15 | components: Component[]; 16 | timer = 0; 17 | 18 | constructor() { 19 | this.components = []; 20 | } 21 | 22 | /** 23 | * Registers a new component. 24 | * 25 | * @param {Component} component the component to register. 26 | */ 27 | register(component: Component): void { 28 | this.components.push(component); 29 | } 30 | 31 | /** 32 | * Unregisters a component. 33 | * 34 | * @param {Component} component 35 | */ 36 | unregister(component:Component): void { 37 | this.components.filter((e) => e.id !== component.id); 38 | } 39 | 40 | /** 41 | * Starts the component loader. 42 | */ 43 | async start(): Promise { 44 | let storage = await SyncStorage.COMPONENTS.get() as {[key: string]: { enabled: boolean }}; 45 | 46 | if (storage == null) { 47 | storage = {}; 48 | } 49 | 50 | this.components.forEach((component) => { 51 | if (storage[component.tag] == null) { 52 | storage[component.tag] = { enabled: true }; 53 | } 54 | 55 | try { 56 | component.enabled = storage[component.tag].enabled; 57 | if (component.enabled && !component.active) component.load(); 58 | } catch (e) { 59 | Logger.error(e); 60 | } 61 | }); 62 | 63 | void SyncStorage.COMPONENTS.set(storage); 64 | this.startTimer(); 65 | } 66 | 67 | /** 68 | * Stops the component loader. 69 | */ 70 | stop(): void { 71 | this.components.forEach((component) => { 72 | if (component.active) component.unload(); 73 | }); 74 | this.stopTimer(); 75 | } 76 | 77 | private startTimer() { 78 | this.timer = setInterval(() => { 79 | this.components.forEach((component) => { 80 | if (component.active) component.onUpdate(); 81 | }); 82 | }, 1000) as unknown as number; 83 | } 84 | 85 | private stopTimer() { 86 | clearInterval(this.timer); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Stadia+ Extension", 3 | "short_name": "Stadia+", 4 | "version": "2.5.8", 5 | "author": "Malte Klüft (Mafrans)", 6 | "description": "Extends Google's Stadia gaming platform with additional features, such as custom filters and in game network monitoring.", 7 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAj4rm88698t+T5Pe5jWP8edM6wPBErxwCu0k3Ona/Xc3lt3NJpvW5dabUdcpP3MYBjMQ41V/iq4Q64rObL9csaruxP4Ex3S8SaxBc/sDBxAUjRdYebF76t+APSeTRDs0WiKJgjUoEGBlOtSqffVYO7wpLr/IWUn9z1PfkaV5LY5jrlHJ4o0HUOoVW5v2gTUZV143hdOXQgMH9IFf1MppMzg0m7AVq+8j7L7O5334T0tKzhb2sd9uHp74jv2jTWBK1ykkoDt4ST18ZC6zdXH0/4rI3cJ/r0YlganD6wfNvJTWJ3WMCrvR/7S//qBr9iNrD65BQDFln90JPEeBScjtd8wIDAQAB", 8 | "permissions": [ 9 | "declarativeContent", 10 | "storage", 11 | "identity", 12 | "https://stadiagamedb.com/data/gamedb.json", 13 | "https://stadiaplus.dev/*" 14 | ], 15 | "content_security_policy": "script-src 'self' 'unsafe-eval' http://localhost:8098; object-src 'self'", 16 | "oauth2": { 17 | "client_id": "401608975485-29rkti0stvi4odvnn6hlomkjs0lrtqbj.apps.googleusercontent.com", 18 | "scopes": [ 19 | "https://stadiaplus.dev/auth/google", 20 | "https://stadiaplus.dev/auth/google/callback" 21 | ] 22 | }, 23 | "background": { 24 | "scripts": [ 25 | "background.js" 26 | ], 27 | "persistent": false 28 | }, 29 | "browser_action": { 30 | "default_popup": "dist/popup.html", 31 | "default_icon": { 32 | "16": "images/Stadia+16.png", 33 | "32": "images/Stadia+32.png", 34 | "48": "images/Stadia+48.png", 35 | "128": "images/Stadia+128.png" 36 | } 37 | }, 38 | "icons": { 39 | "16": "images/Stadia+16.png", 40 | "32": "images/Stadia+32.png", 41 | "48": "images/Stadia+48.png", 42 | "128": "images/Stadia+128.png" 43 | }, 44 | "content_scripts": [ 45 | { 46 | "run_at": "document_start", 47 | "matches": [ 48 | "https://stadia.google.com/*" 49 | ], 50 | "js": [ 51 | "dist/app.js" 52 | ] 53 | } 54 | ], 55 | "web_accessible_resources": [ 56 | "images/icons/stadiaplus.svg", 57 | "images/icons/network-monitor.svg", 58 | "images/icons/windowed.svg", 59 | "images/icons/windowed_exit.svg", 60 | "images/PlayButton.png", 61 | "images/PlayButtonBackground.png" 62 | ], 63 | "manifest_version": 2 64 | } -------------------------------------------------------------------------------- /src/components/styles/StoreFilter.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_storefilter { 2 | overflow: hidden; 3 | margin: 1.5rem 0; 4 | border-radius: 0.5rem; 5 | box-shadow: 0 0.125rem 0.75rem rgba(0,0,0,0.32), 0 0.0625rem 0.375rem rgba(0,0,0,0.18); 6 | 7 | .bar { 8 | background-color: rgba(255,255,255,.12); 9 | padding: 1rem; 10 | align-items: center; 11 | display: flex; 12 | align-content: center; 13 | 14 | &::before { 15 | content: 'search'; 16 | font-size: 32px; 17 | margin-right: 0.5rem; 18 | font-family: 'Material Icons Extended'; 19 | } 20 | 21 | input { 22 | width: calc(100% - 1rem); 23 | padding: 0.5rem; 24 | background-color: rgba(255,255,255,.12); 25 | font-family: 'Google Sans',sans-serif; 26 | font-size: 1.25rem; 27 | outline: #ff773d 3px; 28 | color: #ffffff; 29 | font-weight: 500; 30 | border: none; 31 | border-radius: 0.25rem; 32 | } 33 | } 34 | 35 | .games { 36 | display: flex; 37 | flex-flow: column wrap; 38 | background-color: rgba(255,255,255,.06); 39 | 40 | .stadiaplus_storefilter-game { 41 | display: inline-flex; 42 | overflow: hidden; 43 | opacity: 0; 44 | height: 0; 45 | align-content: center; 46 | border-radius: 0.5rem; 47 | background-color: rgba(255,255,255,.06); 48 | margin: 0 1rem; 49 | box-shadow: 0 0 0 0.1875rem transparent; 50 | color: #ffffff; 51 | transition: height 0.2s ease-out, margin 0.2s ease-out, opacity 0.2s ease-out; 52 | 53 | &.shown { 54 | height: 90px; 55 | margin: 1rem; 56 | opacity: 1; 57 | 58 | ~ .shown { 59 | margin-top: -0.5rem; 60 | } 61 | 62 | &:hover { 63 | background-color: rgba(255,255,255,.09); 64 | } 65 | } 66 | 67 | img { 68 | object-fit: cover; 69 | width: 140px; 70 | border-top-left-radius: 0.5rem; 71 | border-bottom-left-radius: 0.5rem; 72 | } 73 | 74 | .detail { 75 | display: flex; 76 | flex-direction: column; 77 | justify-content: center; 78 | margin-left: 1.5rem; 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/popup/src/UserPage.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 73 | 74 | 80 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TypedocWebpackPlugin = require('typedoc-webpack-plugin'); 3 | const VueLoaderPlugin = require('vue-loader/lib/plugin'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | module.exports = (env) => ({ 7 | entry: { 8 | app: './src/index.js', 9 | popup: './src/popup/src/main.js', 10 | }, 11 | devtool: 'inline-source-map', 12 | mode: env.production ? 'production' : 'development', 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.tsx?$/, 17 | use: 'ts-loader', 18 | exclude: /node_modules/, 19 | }, 20 | { 21 | test: /\.vue$/, 22 | loader: 'vue-loader', 23 | }, 24 | { 25 | test: /\.css$/i, 26 | use: [ 27 | // Creates `style` nodes from JS strings 28 | { 29 | loader: 'style-loader', 30 | options: { 31 | insert: 'html', 32 | }, 33 | }, 34 | 'css-loader', 35 | ], 36 | }, 37 | 38 | { 39 | test: /\.s[ac]ss$/i, 40 | use: [ 41 | // Creates `style` nodes from JS strings 42 | { 43 | loader: 'style-loader', 44 | options: { 45 | insert: 'html', 46 | }, 47 | }, 48 | // Translates CSS into CommonJS 49 | 'css-loader', 50 | // Compiles Sass to CSS 51 | 'sass-loader', 52 | ], 53 | }, 54 | { 55 | test: /\.(png|jpe?g|gif|svg)$/i, 56 | use: [ 57 | { 58 | loader: 'file-loader', 59 | }, 60 | ], 61 | }, 62 | { 63 | test: /\.txt$/i, 64 | use: 'raw-loader', 65 | }, 66 | ], 67 | }, 68 | plugins: [ 69 | new VueLoaderPlugin(), 70 | new TypedocWebpackPlugin({ 71 | name: 'Contoso', 72 | mode: 'file', 73 | out: './docs', 74 | }, './src'), 75 | new HtmlWebpackPlugin({ 76 | chunks: ['popup'], 77 | filename: 'popup.html', 78 | }), 79 | ], 80 | resolve: { 81 | extensions: ['.tsx', '.ts', '.js'], 82 | }, 83 | output: { 84 | filename: '[name].js', 85 | path: path.resolve(__dirname, 'dist'), 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /src/ui/Select.ts: -------------------------------------------------------------------------------- 1 | import SlimSelect from 'slim-select'; 2 | import 'slim-select/dist/slimselect.min.css'; 3 | import { SelectStyle } from '../models/SelectStyle'; 4 | import { SelectOptions } from '../models/SelectOptions'; 5 | import './styles/Select.scss'; 6 | import Logger from '../Logger'; 7 | 8 | export class Select { 9 | slimselect: SlimSelect | undefined; 10 | element: Element | null; 11 | 12 | constructor(element: Element, options: SelectOptions) { 13 | this.element = element; 14 | 15 | options.style = options.style !== undefined ? options.style : SelectStyle.DARK; 16 | 17 | this.element.classList.add( 18 | 'stadiaplus_select', 19 | options.style, 20 | ); 21 | 22 | /** 23 | * Slimselect throws a TypeError if the elements/containers 24 | * have been deleted without properly being destroyed. As Stadia 25 | * runs in a virtual DOM, we have no control of when the DOM changes 26 | * therefore we can't solve it in a proper way. 27 | * 28 | * Let's just hope garbage collection takes care of it. 29 | */ 30 | try { 31 | this.slimselect = new SlimSelect({ 32 | select: this.element, 33 | showSearch: false, 34 | placeholder: options.placeholder, 35 | onChange: options.onChange, 36 | beforeOnChange: options.beforeChange, 37 | }); 38 | } catch (error) { 39 | Logger.error(error); 40 | } 41 | } 42 | 43 | disable(): void { 44 | if (this.element == null) return; 45 | this.element.classList.add('disabled'); 46 | } 47 | 48 | enable(): void { 49 | if (this.element == null) return; 50 | this.element.classList.remove('disabled'); 51 | } 52 | 53 | get(): string | string[] { 54 | if (this.slimselect == null) return 'undefined'; 55 | return this.slimselect.selected(); 56 | } 57 | 58 | set(...items: unknown[]): void { 59 | if (this.slimselect == null) return; 60 | 61 | this.slimselect.setData( 62 | items as never[], 63 | ); 64 | } 65 | 66 | select(item: unknown): void { 67 | if (this.slimselect == null) return; 68 | 69 | this.slimselect.setSelected(item as never); 70 | } 71 | 72 | search(query: string): void { 73 | if (this.slimselect == null) return; 74 | this.slimselect.search(query); 75 | } 76 | 77 | destroy(): void { 78 | if (this.element == null) return; 79 | if (this.slimselect !== undefined) { 80 | this.slimselect.destroy(); 81 | } 82 | this.element.classList.remove('stadiaplus_select'); 83 | this.element = null; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/PasteFromClipboard.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../Component'; 2 | import Logger from '../Logger'; 3 | import Util from '../util/Util'; 4 | 5 | export class Platform { 6 | static WINDOWS = 'Win32'; 7 | static MACOS = 'MacIntel'; 8 | } 9 | 10 | export class PasteFromClipboard extends Component { 11 | tag = 'paste-from-clipboard'; 12 | 13 | protected target: HTMLInputElement | null = null; 14 | 15 | /** 16 | * Called on startup, initializes important variables. 17 | */ 18 | onStart(): void { 19 | this.active = true; 20 | } 21 | 22 | /** 23 | * Called on stop, makes sure to dispose of elements and variables. 24 | */ 25 | onStop(): void { 26 | this.active = false; 27 | } 28 | 29 | /** 30 | * Called once every second. 31 | */ 32 | onUpdate(): void { 33 | super.onUpdate(); 34 | 35 | if (Util.isInGame()) { 36 | this.updateRenderer(); 37 | 38 | if (this.renderer === undefined) { 39 | Logger.error('Renderer is undefined'); 40 | return; 41 | } 42 | 43 | const input: HTMLInputElement = this.renderer.getElementsByTagName('input')[0]; 44 | 45 | if (input !== this.target) { 46 | if (undefined !== this.target) { 47 | this.target?.removeEventListener('keydown', (...args) => this.keydownEventListener(...args)); 48 | } 49 | this.target = input; 50 | this.target.addEventListener('keydown', (...args) => this.keydownEventListener(...args)); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * @param event 57 | */ 58 | keydownEventListener(event: KeyboardEvent): void { 59 | let ctrlKey: boolean; 60 | switch (navigator.platform) { 61 | case Platform.WINDOWS: 62 | ctrlKey = event.ctrlKey; 63 | break; 64 | 65 | case Platform.MACOS: 66 | ctrlKey = event.metaKey; 67 | break; 68 | 69 | default: 70 | ctrlKey = event.ctrlKey; 71 | break; 72 | } 73 | 74 | if (ctrlKey && event.code === 'KeyV') { 75 | void navigator.clipboard.readText().then((data: string) => { 76 | event.target?.dispatchEvent(new InputEvent('input', { 77 | // InputEventInit 78 | data, 79 | inputType: 'insertText', 80 | isComposing: false, 81 | 82 | // UIEventInit 83 | view: null, 84 | 85 | // EventInit 86 | bubbles: true, 87 | cancelable: false, 88 | composed: true, 89 | })); 90 | }); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/popup/src/MainPage.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 91 | 92 | 94 | -------------------------------------------------------------------------------- /src/ui/UIButton.ts: -------------------------------------------------------------------------------- 1 | import { UIButtonContainer } from './UIButtonContainer'; 2 | 3 | export class UIButton { 4 | id: string; 5 | html: string; 6 | element: Element; 7 | container?: UIButtonContainer; 8 | button: HTMLElement; 9 | 10 | static buttonContainers: UIButtonContainer[] = []; 11 | 12 | constructor(icon: string, title: string, id: string) { 13 | this.id = id; 14 | this.html = ` 15 |
16 |
17 | 18 | 19 | 20 | ${title} 21 |
22 |
23 | `; 24 | 25 | this.element = document.createElement('div'); 26 | this.element.id = id; 27 | this.element.classList.add('Pyf1bb', 'stadiaplus_ui-button'); 28 | 29 | this.button = document.createElement('div'); 30 | this.button.setAttribute('role', 'button'); 31 | this.button.setAttribute('tabindex', '0'); 32 | this.button.classList.add('CTvDXd', 'QAAyWd', 'Pjpac', 'zcMYd'); 33 | this.button.innerHTML = this.html; 34 | this.element.appendChild(this.button); 35 | } 36 | 37 | create(callback?: () => void): void { 38 | UIButton.buttonContainers.forEach((container) => { 39 | if (container.buttons.length < 3) { 40 | this.container = container; 41 | } 42 | }); 43 | 44 | if (this.container === undefined) { 45 | this.container = new UIButtonContainer(); 46 | UIButton.buttonContainers.push(this.container); 47 | } 48 | this.container.addButton(this); 49 | this.container.create(callback); 50 | } 51 | 52 | setIcon(icon: string): void { 53 | const iconElement = this.element.querySelector('.uibutton-icon'); 54 | if (iconElement !== null) { 55 | iconElement.setAttribute('src', icon); 56 | } 57 | } 58 | 59 | setTitle(title: string): void { 60 | const titleElement = this.element.querySelector('.uibutton-title'); 61 | if (titleElement !== null) { 62 | titleElement.textContent = title; 63 | } 64 | } 65 | 66 | update(): void { 67 | if (!this.exists()) { 68 | this.create(); 69 | } 70 | } 71 | 72 | exists(): boolean { 73 | return document.getElementById(this.id) !== null; 74 | } 75 | 76 | destroy(): void { 77 | this.element.remove(); 78 | if (this.container !== undefined) { 79 | this.container.removeButton(this); 80 | } 81 | } 82 | 83 | onPressed(func: (event: Event) => void): void { 84 | this.button.addEventListener('click', func); 85 | this.button.addEventListener('keyup', (event: KeyboardEvent) => { 86 | if (event.key === 'Enter') { 87 | this.button.click(); 88 | } 89 | }); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Logger from './Logger'; 2 | import Util from './util/Util'; 3 | import './styles/Global.scss'; 4 | 5 | import { ComponentLoader } from './ComponentLoader'; 6 | import { Clock } from './components/Clock'; 7 | import { UITab } from './components/UITab'; 8 | import { ForceCodec } from './components/ForceCodec'; 9 | import { ForceResolution } from './components/ForceResolution'; 10 | import { NetworkMonitor } from './components/NetworkMonitor'; 11 | import { Snackbar } from './ui/Snackbar'; 12 | import { LibraryFilter } from './components/LibraryFilter'; 13 | import { StoreFilter } from './components/StoreFilter'; 14 | import { Ratings } from './components/Ratings'; 15 | import { Language } from './Language'; 16 | import { AllowWindowedMode } from './components/AllowWindowedMode'; 17 | import { PasteFromClipboard } from './components/PasteFromClipboard'; 18 | import { LocalStorage, StorageManager } from './Storage'; 19 | import appdata from './appdata.json'; 20 | import { Modal } from './ui/Modal'; 21 | import { Browser } from './Browser'; 22 | import { StadiaPlusDB } from './StadiaPlusDB'; 23 | import { StadiaPlusDBHook } from './components/StadiaPlusDBHook'; 24 | import { StadiaGameDB } from './StadiaGameDB'; 25 | 26 | // Always load languages first 27 | Language.init(); 28 | Language.load(); 29 | 30 | Browser.init(); 31 | 32 | StadiaGameDB.update(); 33 | 34 | const storageManager = new StorageManager(appdata); 35 | storageManager.checkCacheVersion(); 36 | 37 | const loader = new ComponentLoader(); 38 | const tab = new UITab(); 39 | const webScraper = new StadiaPlusDBHook(); 40 | 41 | loader.register(new Clock()); 42 | // loader.register(new PopupFix()); 43 | loader.register(new LibraryFilter(webScraper)); 44 | loader.register(new ForceCodec(tab)); 45 | loader.register(new ForceResolution(tab)); 46 | loader.register(tab); 47 | loader.register(new NetworkMonitor()); 48 | loader.register(new StoreFilter()); 49 | loader.register(new Ratings()); 50 | loader.register(new AllowWindowedMode()); 51 | loader.register(new PasteFromClipboard()); 52 | // loader.register(new LookingForGroup()); 53 | loader.register(webScraper); 54 | 55 | StadiaPlusDB.connect('https://stadiaplus.dev') 56 | .then((connected) => { 57 | if (!connected) { 58 | Logger.error('StadiaPlusDB was unable to connect, is the server down?'); 59 | return; 60 | } 61 | 62 | LocalStorage.AUTH_TOKEN.get() 63 | .then((token) => { 64 | StadiaPlusDB.authToken = token; 65 | 66 | StadiaPlusDB.getProfile() 67 | .then((profile) => { 68 | Logger.info(Language.get('stadiaplusdb.signed-in', { user: profile.name + (profile.tag === '0000' ? '✨' : `#${profile.tag}`) })); 69 | }) 70 | .catch(() => { 71 | StadiaPlusDB.authToken = null; 72 | Logger.error('Not logged into Stadia+'); 73 | }); 74 | }); 75 | }); 76 | 77 | window.addEventListener('load', () => { 78 | Util.load(); 79 | Snackbar.init(); 80 | Modal.init(); 81 | loader.start(); 82 | }); 83 | -------------------------------------------------------------------------------- /src/ui/styles/Select.scss: -------------------------------------------------------------------------------- 1 | .stadiaplus_select { 2 | font-family: 'Google Sans', sans-serif; 3 | font-size: 18px; 4 | 5 | &.disabled { 6 | opacity: 0.6; 7 | pointer-events: none; 8 | cursor: default; 9 | } 10 | 11 | &.style-dark { 12 | &.ss-main { 13 | border-color: #3C3E43; 14 | width: auto; 15 | 16 | .ss-content { 17 | border-color: #3C3E43; 18 | 19 | .ss-list .ss-option { 20 | background-color: #3C3E43; 21 | color: rgba(255, 255, 255, 0.8); 22 | 23 | &.ss-disabled { 24 | background-color: #3C3E43; 25 | color: rgba(255, 255, 255, 0.4); 26 | } 27 | } 28 | } 29 | 30 | .ss-multi-selected, 31 | .ss-single-selected { 32 | background: transparent; 33 | border: none; 34 | border-bottom: #93959F 1px solid; 35 | border-radius: 0; 36 | width: 180px; 37 | 38 | .placeholder { 39 | color: rgba(255, 255, 255, 0.8); 40 | font-size: 16px; 41 | } 42 | 43 | .ss-plus span, 44 | .ss-arrow span { 45 | border-color: #93959F; 46 | } 47 | 48 | .ss-option { 49 | font-size: 18px; 50 | padding: 4px 8px; 51 | } 52 | 53 | .ss-value { 54 | margin: 0px 8px 8px 0; 55 | background-color: rgba(255, 255, 255, 0.12); 56 | font-size: 16px; 57 | border-radius: 999px; 58 | padding: 0.25rem 0.5rem; 59 | 60 | .ss-value-delete { 61 | font-family: 'Material Icons Extended'; 62 | margin-left: -5px; 63 | font-size: 13px; 64 | 65 | &::after { 66 | content: 'close'; 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | &.style-light { 75 | &.ss-main { 76 | width: auto; 77 | 78 | .ss-multi-selected, 79 | .ss-single-selected { 80 | background: transparent; 81 | border: none; 82 | border-bottom: #cccccc 1px solid; 83 | border-radius: 0; 84 | width: 180px; 85 | 86 | .ss-plus span, 87 | .ss-arrow span { 88 | border-color: #cccccc; 89 | } 90 | } 91 | } 92 | } 93 | 94 | &.style-slimselect-large { 95 | &.ss-main { 96 | width: 200px; 97 | height: 40px; 98 | 99 | .ss-multi-selected, 100 | .ss-single-selected { 101 | height: 100%; 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /src/util/ElGen.ts: -------------------------------------------------------------------------------- 1 | export class ElGen { 2 | element: HTMLElement; 3 | 4 | constructor(element: string | HTMLElement) { 5 | if (element instanceof HTMLElement) { 6 | this.element = element; 7 | } else { 8 | this.element = document.createElement(element); 9 | } 10 | } 11 | 12 | id(id: string): ElGen { 13 | this.element.id = id; 14 | return this; 15 | } 16 | 17 | css(css: {[key: string]: string}): ElGen { 18 | Object.keys(css).forEach((key: string) => { 19 | this.element.style.setProperty(key, css[key]); 20 | }); 21 | return this; 22 | } 23 | 24 | appendTo(element: Node | ElGen): void { 25 | if (element instanceof ElGen) { 26 | (element).element.appendChild(this.element); 27 | } else { 28 | (element).appendChild(this.element); 29 | } 30 | } 31 | 32 | prependTo(element: Element | ElGen): void { 33 | if (element instanceof ElGen) { 34 | (element).element.prepend(this.element); 35 | } else { 36 | (element).prepend(this.element); 37 | } 38 | } 39 | 40 | child(child: HTMLElement | ElGen): ElGen { 41 | if (child instanceof HTMLElement) { 42 | this.element.appendChild((child)); 43 | } else { 44 | this.element.appendChild((child).element); 45 | } 46 | return this; 47 | } 48 | 49 | attr(attribute: {[name: string]: unknown}): ElGen { 50 | Object.keys(attribute).forEach((name) => { 51 | this.element.setAttribute(name, attribute[name] as string); 52 | }); 53 | return this; 54 | } 55 | 56 | class(classes: {[name: string]: boolean}): ElGen { 57 | Object.keys(classes).forEach((name) => { 58 | this.element.classList.toggle(name, classes[name]); 59 | }); 60 | return this; 61 | } 62 | 63 | text(text: string): ElGen { 64 | this.element.textContent = text; 65 | return this; 66 | } 67 | 68 | /** 69 | * @deprecated innerHTML is slow and should only be used if no other solution is sufficient. 70 | * @param html The html to add to this element. 71 | */ 72 | html(html: string): ElGen { 73 | this.element.innerHTML = html; 74 | return this; 75 | } 76 | 77 | event(events: {[name: string]: (event: Event) => void}): ElGen { 78 | Object.keys(events).forEach((name) => { 79 | this.element.addEventListener(name, events[name]); 80 | }); 81 | return this; 82 | } 83 | 84 | $sel(selector: string): ElGen | null { 85 | const element = this.element.querySelector(selector) as HTMLElement; 86 | return element != null ? new ElGen(element) : null; 87 | } 88 | } 89 | 90 | function $el(tag: string): ElGen { 91 | return new ElGen(tag); 92 | } 93 | 94 | function $sel(element: string | HTMLElement): ElGen { 95 | if (element instanceof HTMLElement) { 96 | return new ElGen(element); 97 | } 98 | return new ElGen(document.querySelector(element) as HTMLElement); 99 | } 100 | 101 | export { $el, $sel }; 102 | -------------------------------------------------------------------------------- /src/popup/src/SettingsPage.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 89 | 90 | 97 | -------------------------------------------------------------------------------- /src/popup/src/App.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 52 | 53 | 136 | -------------------------------------------------------------------------------- /src/models/FilterOrder.ts: -------------------------------------------------------------------------------- 1 | import { LibraryGame } from './LibraryGame'; 2 | import Util from '../util/Util'; 3 | 4 | /** 5 | * Different types of filtering, represented as numbers 6 | * 7 | * @export the FilterOrder type 8 | * @class FilterOrder 9 | */ 10 | 11 | export class FilterOrder { 12 | public id!: number; 13 | public name!: string; 14 | public sort!: (games: LibraryGame[]) => LibraryGame[]; 15 | 16 | /** 17 | * Default Stadia sorting, recent/new games. 18 | * 19 | * @static 20 | * @memberof FilterOrder 21 | */ 22 | static RECENT: FilterOrder = { 23 | id: 0, 24 | name: 'recent', 25 | sort: (games) => FilterOrder.sortRecent(games), 26 | }; 27 | 28 | /** 29 | * Alphabetical order. 30 | * 31 | * @static 32 | * @memberof FilterOrder 33 | */ 34 | static ALPHABETICAL: FilterOrder = { 35 | id: 1, 36 | name: 'alphabetical', 37 | sort: (games) => FilterOrder.sortAlphabetical(games), 38 | }; 39 | 40 | /** 41 | * Random order. 42 | * 43 | * @static 44 | * @memberof FilterOrder 45 | */ 46 | static RANDOM: FilterOrder = { 47 | id: 2, 48 | name: 'random', 49 | sort: (games) => FilterOrder.sortRandom(games), 50 | }; 51 | 52 | static from(id: number): FilterOrder { 53 | const order = this.values().find((e) => e.id === id); 54 | 55 | if (order === undefined) return FilterOrder.RECENT; 56 | return order; 57 | } 58 | 59 | static values(): FilterOrder[] { 60 | return [FilterOrder.RECENT, FilterOrder.ALPHABETICAL, FilterOrder.RANDOM]; 61 | } 62 | 63 | /** 64 | * Get the sorting method of the inputed order. 65 | * 66 | * @static 67 | * @returns a function sorting games by the inputed order. 68 | * @param {FilterOrder} order 69 | * @memberof FilterOrder 70 | */ 71 | static getSorter(order: FilterOrder): (games: LibraryGame[]) => LibraryGame[] { 72 | switch (order) { 73 | case this.RECENT: 74 | return (games) => FilterOrder.sortRecent(games); 75 | 76 | case this.ALPHABETICAL: 77 | return (games) => FilterOrder.sortAlphabetical(games); 78 | 79 | case this.RANDOM: 80 | return (games) => FilterOrder.sortRandom(games); 81 | 82 | default: 83 | return (games) => FilterOrder.sortRecent(games); 84 | } 85 | } 86 | 87 | /** 88 | * Sort by recent games. 89 | * 90 | * @private 91 | * @static 92 | * @param {*} a 93 | * @param {*} b 94 | * @returns number representing which parameter is where. 95 | * @memberof FilterOrder 96 | */ 97 | private static sortRecent(games: LibraryGame[]): LibraryGame[] { 98 | return games; 99 | } 100 | 101 | /** 102 | * Sort alphabetically. 103 | * 104 | * @private 105 | * @static 106 | * @param {*} a 107 | * @param {*} b 108 | * @returns number representing which parameter is where. 109 | * @memberof FilterOrder 110 | */ 111 | private static sortAlphabetical(games: LibraryGame[]): LibraryGame[] { 112 | return games.sort((a, b) => a.name.localeCompare(b.name)); 113 | } 114 | 115 | private static sortRandom(games: LibraryGame[]): LibraryGame[] { 116 | return Util.shuffle(games); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/components/StadiaPlusDBHook.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 3 | /* eslint-disable import/no-webpack-loader-syntax */ 4 | import { Component } from '../Component'; 5 | import Logger from '../Logger'; 6 | import Util from '../util/Util'; 7 | import { Language } from '../Language'; 8 | import { StadiaPlusDB } from '../StadiaPlusDB'; 9 | import './styles/StadiaPlusDBHook.scss'; 10 | 11 | // Import the runnable as a raw string 12 | // @ts-ignore 13 | import runnable from '!raw-loader!../util/WebScraperRunnable'; 14 | 15 | interface StadiaPlusDBGameData { 16 | game: { 17 | name: string; 18 | } 19 | } 20 | 21 | /** 22 | * A web scraper that tracks http requests and parses them. 23 | * 24 | * @export the WebScraper type. 25 | * @class WebScraper 26 | * @extends {Component} 27 | */ 28 | export class StadiaPlusDBHook extends Component { 29 | /** 30 | * The component tag, used in language files. 31 | */ 32 | tag = 'stadiaplusdb'; 33 | 34 | /** 35 | * The popup element. 36 | */ 37 | popup: HTMLElement | null = null; 38 | 39 | /** 40 | * Is in game. 41 | */ 42 | inGame = false; 43 | 44 | constructor() { 45 | super(); 46 | 47 | window.addEventListener('DOMContentLoaded', () => { 48 | const sandboxer = document.createElement('button'); 49 | sandboxer.style.display = 'none'; 50 | sandboxer.id = 'web-scraper-sandboxer'; 51 | document.body.appendChild(sandboxer); 52 | sandboxer.addEventListener('click', () => { 53 | const dataString = sandboxer.getAttribute('data'); 54 | 55 | if (dataString !== null) { 56 | const data = JSON.parse(dataString) as StadiaPlusDBGameData; 57 | Logger.info(Language.get('stadiaplusdb.updating', { game: data.game.name })); 58 | StadiaPlusDB.ProfileConnector.setData(data) 59 | .catch((err) => Logger.error(err)); 60 | } 61 | }); 62 | 63 | const script = document.createElement('script'); 64 | script.innerHTML = runnable as string; 65 | document.body.appendChild(script); 66 | }); 67 | } 68 | 69 | /** 70 | * Called on startup, logs to the console. 71 | * 72 | * @memberof WebScraper 73 | */ 74 | onStart(): void { 75 | this.active = true; 76 | Logger.component(Language.get('component.enabled', { name: this.name })); 77 | } 78 | 79 | /** 80 | * Called on stop, logs to the console. 81 | * 82 | * @memberof WebScraper 83 | */ 84 | onStop(): void { 85 | this.active = false; 86 | Logger.component(Language.get('component.disabled', { name: this.name })); 87 | } 88 | 89 | updateGame(uuid: string): void { 90 | Util.desandbox(`WebScraperRunnable.update('${uuid}')`); 91 | } 92 | 93 | oldURL = ''; 94 | onUpdate(): void { 95 | if (StadiaPlusDB.isAuthenticated()) { 96 | if (location.href !== this.oldURL) { 97 | if (location.href.includes('player')) { 98 | Util.desandbox(`WebScraperRunnable.update('${location.href.split('/').pop() as string}')`); 99 | } else if (this.oldURL.includes('player')) { 100 | Util.desandbox(`WebScraperRunnable.update('${this.oldURL.split('/').pop() as string}')`); 101 | } 102 | this.oldURL = location.href; 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/ui/Checkbox.ts: -------------------------------------------------------------------------------- 1 | import '../../node_modules/pretty-checkbox/src/pretty-checkbox.scss'; 2 | import { CheckboxShape } from '../models/CheckboxShape'; 3 | import { CheckboxStyle } from '../models/CheckboxStyle'; 4 | import { CheckboxInstance } from '../models/CheckboxInstance'; 5 | 6 | export class Checkbox { 7 | private label: string; 8 | private shape: string = CheckboxShape.DEFAULT; 9 | private style: string = CheckboxStyle.DEFAULT; 10 | private color: string | undefined; 11 | private animation: string | undefined; 12 | private border = true; 13 | private icon: string | undefined; 14 | private disabled = false; 15 | private bigger: boolean | undefined; 16 | 17 | constructor(label: string) { 18 | this.label = label; 19 | } 20 | 21 | setShape(shape: string): Checkbox { 22 | this.shape = shape; 23 | return this; 24 | } 25 | 26 | setStyle(style: string): Checkbox { 27 | this.style = style; 28 | return this; 29 | } 30 | 31 | setColor(color: string): Checkbox { 32 | this.color = color; 33 | return this; 34 | } 35 | 36 | setAnimation(animation: string): Checkbox { 37 | this.animation = animation; 38 | return this; 39 | } 40 | 41 | setBorder(border: boolean): Checkbox { 42 | this.border = border; 43 | return this; 44 | } 45 | 46 | setIcon(icon: string): Checkbox { 47 | this.icon = icon; 48 | return this; 49 | } 50 | 51 | setDisabled(disabled: boolean): Checkbox { 52 | this.disabled = disabled; 53 | return this; 54 | } 55 | 56 | setBigger(bigger: boolean): Checkbox { 57 | this.bigger = bigger; 58 | return this; 59 | } 60 | 61 | build(): CheckboxInstance { 62 | // Create element 63 | const element = document.createElement('div'); 64 | 65 | // Add main classes 66 | element.classList.add('pretty', 'p-default'); 67 | 68 | // If style is not default, add style 69 | if (this.shape) { 70 | element.classList.add(this.shape); 71 | } 72 | 73 | // If style is not default, add style 74 | if (this.style) { 75 | element.classList.add(this.style); 76 | } 77 | 78 | // If animated, add animation 79 | if (this.animation) { 80 | element.classList.add(this.animation); 81 | } 82 | 83 | // Set bigger 84 | if (this.bigger) { 85 | element.classList.add('p-bigger'); 86 | } 87 | 88 | // Set border 89 | if (!this.border) { 90 | element.classList.add('p-plain'); 91 | } 92 | 93 | // Add checkbox input 94 | const checkbox = document.createElement('input'); 95 | checkbox.type = 'checkbox'; 96 | checkbox.disabled = this.disabled; 97 | element.appendChild(checkbox); 98 | 99 | // Add state div 100 | const state = document.createElement('div'); 101 | state.classList.add('state'); 102 | 103 | // If colored, add color 104 | if (this.color) { 105 | state.classList.add(this.color); 106 | } 107 | 108 | // If has icon, add icon 109 | if (this.icon) { 110 | element.classList.add('p-icon'); 111 | 112 | const icon = document.createElement('span'); 113 | icon.classList.add('material-icons'); 114 | icon.innerHTML = this.icon; 115 | 116 | state.appendChild(icon); 117 | } 118 | 119 | // Add label 120 | const label = document.createElement('label'); 121 | label.innerHTML = this.label; 122 | state.appendChild(label); 123 | 124 | element.appendChild(state); 125 | 126 | return { pretty: element, checkbox }; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/popup/src/ComponentPage.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 80 | 81 | 120 | -------------------------------------------------------------------------------- /src/Storage.ts: -------------------------------------------------------------------------------- 1 | import { AppdataManifest } from './models/AppdataManifest'; 2 | 3 | export class LocalStorage { 4 | static CODEC = new LocalStorage('Codec', 'codec'); 5 | static RESOLUTION = new LocalStorage('Resolution', 'resolution'); 6 | static MONITOR_STATS = new LocalStorage('Monitor Stats', 'monitor-stats'); 7 | static CACHE_VERSION = new LocalStorage('Cache Version', 'cache-version'); 8 | static AUTH_TOKEN = new LocalStorage('Authentication Token', 'auth-token'); 9 | 10 | name: string; 11 | tag: string; 12 | 13 | constructor(name: string, tag: string) { 14 | this.name = name; 15 | this.tag = tag; 16 | } 17 | 18 | get(): Promise { 19 | return new Promise((resolve) => { 20 | chrome.storage.local.get( 21 | [this.tag], 22 | (result: { [tag: string]: unknown }) => resolve(result[this.tag]), 23 | ); 24 | }); 25 | } 26 | 27 | set(value: unknown): Promise { 28 | return new Promise((resolve) => { 29 | chrome.storage.local.set({ [this.tag]: value }, resolve); 30 | }); 31 | } 32 | 33 | static get(storages: LocalStorage[]): Promise { 34 | return new Promise((resolve) => { 35 | chrome.storage.local.get(storages.map((e) => e.tag), resolve); 36 | }); 37 | } 38 | 39 | static set(storages: { [key: string]: unknown }): Promise { 40 | return new Promise((resolve) => { 41 | chrome.storage.local.set(storages, resolve); 42 | }); 43 | } 44 | 45 | static clear(): void { 46 | chrome.storage.local.clear(); 47 | } 48 | } 49 | 50 | export class SyncStorage { 51 | static LIBRARY_GAMES = new SyncStorage('Library Games', 'games'); 52 | static LIBRARY_SORT_ORDER = new SyncStorage('Sort Order', 'sort-order'); 53 | static LIBRARY_SORT_DIRECTION = new SyncStorage('Sort Direction', 'sort-direction'); 54 | static LANGUAGE = new SyncStorage('Language', 'language'); 55 | static COMPONENTS = new SyncStorage('Components', 'components'); 56 | 57 | name: string; 58 | tag: string; 59 | 60 | constructor(name: string, tag: string) { 61 | this.name = name; 62 | this.tag = tag; 63 | } 64 | 65 | get(): Promise { 66 | return new Promise((resolve) => { 67 | chrome.storage.sync.get( 68 | [this.tag], 69 | (result: { [tag: string]: unknown }) => resolve(result[this.tag]), 70 | ); 71 | }); 72 | } 73 | 74 | set(value: unknown): Promise { 75 | return new Promise((resolve) => { 76 | chrome.storage.sync.set({ [this.tag]: value }, resolve); 77 | }); 78 | } 79 | 80 | static get(storages: LocalStorage[]): Promise { 81 | return new Promise((resolve) => { 82 | chrome.storage.sync.get(storages.map((e) => e.tag), resolve); 83 | }); 84 | } 85 | 86 | static set(storages: { [key: string]: unknown }): Promise { 87 | return new Promise((resolve) => { 88 | chrome.storage.sync.set(storages, resolve); 89 | }); 90 | } 91 | 92 | static clear(): void { 93 | chrome.storage.sync.clear(); 94 | } 95 | } 96 | 97 | export class StorageManager { 98 | appdata: AppdataManifest; 99 | constructor(appdata: AppdataManifest) { 100 | this.appdata = appdata; 101 | } 102 | 103 | async checkCacheVersion(): Promise { 104 | const cacheVersion = await LocalStorage.CACHE_VERSION.get() as number; 105 | 106 | if (cacheVersion === undefined || this.appdata['cache-version'] > cacheVersion) { 107 | this.appdata['clear-keys'].local.forEach((key: string) => { 108 | void LocalStorage.set({ [key]: null }); 109 | }); 110 | 111 | this.appdata['clear-keys'].sync.forEach((key: string) => { 112 | void SyncStorage.set({ [key]: null }); 113 | }); 114 | } 115 | 116 | void LocalStorage.CACHE_VERSION.set(this.appdata['cache-version']); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/util/WebScraperRunnable.js: -------------------------------------------------------------------------------- 1 | const WebScraperRunnable = { 2 | games: [], 3 | 4 | fetchData(userid, gameid) { 5 | return new Promise((resolve, reject) => { 6 | fetch(`https://stadia.google.com/profile/${userid}/detail/${gameid}`) 7 | .then((response) => response.text()) 8 | .then((text) => { 9 | const playData = text.match(new RegExp(`\\[\\[\\["${gameid}",.+\\n.+\\n,\\[([0-9]+)`)); 10 | const achievementData = text.match(new RegExp("AF_initDataCallback\\(\\{ *key: *'ds:3'.*?data: *((.|\\n)*?), *sideChannel: *\\{\\}\\}\\)")); 11 | 12 | if (playData == null) return; 13 | 14 | const data = JSON.parse(achievementData[1])[0]; 15 | 16 | const achievements = []; 17 | for (const e of data[5][0]) { 18 | achievements.push({ 19 | name: e[0], 20 | description: e[1], 21 | value: e[3], 22 | icon: e[8][0][0][1], 23 | game: e[6], 24 | id: e[7], 25 | }); 26 | } 27 | 28 | const user = { 29 | name: data[5][3][0][0], 30 | tag: data[5][3][0][1], 31 | avatar: data[5][3][1][1], 32 | }; 33 | 34 | resolve({ 35 | game: { 36 | uuid: data[0][0], 37 | name: data[0][1], 38 | }, 39 | achievements, 40 | user, 41 | time: playData[1], 42 | }); 43 | }) 44 | .catch(reject); 45 | }); 46 | }, 47 | 48 | update(uuid) { 49 | if (uuid == null) return; 50 | 51 | const userId = document.querySelector('.ksZYgc.VGZcUb').getAttribute('data-player-id'); 52 | WebScraperRunnable.fetchData(userId, uuid) 53 | .then((data) => { 54 | const sandboxer = document.getElementById('web-scraper-sandboxer'); 55 | sandboxer.setAttribute('data', JSON.stringify(data)); 56 | sandboxer.click(); 57 | 58 | let updated = localStorage.getItem('updatedGames'); 59 | if (updated != null) { 60 | updated = JSON.parse(updated); 61 | } else { 62 | updated = {}; 63 | } 64 | updated[uuid] = true; 65 | localStorage.setItem('updatedGames', JSON.stringify(updated)); 66 | }) 67 | .catch((e) => console.error(e)); 68 | }, 69 | 70 | autoUpdate: false, 71 | autoUpdateInterval: 2 * 60 * 1000, // Two minutes 72 | setAutoUpdate(value) { 73 | this.autoUpdate = value; 74 | if (this.autoUpdate) { 75 | const loop = () => { 76 | let updated = localStorage.getItem('updatedGames'); 77 | if (updated != null) { 78 | updated = JSON.parse(updated); 79 | } else { 80 | updated = {}; 81 | } 82 | 83 | try { 84 | if (this.games.length > 0) { 85 | let hasUpdated = false; 86 | for (const uuid of this.games) { 87 | if (!updated.hasOwnProperty(uuid) || !updated[uuid]) { 88 | this.update(uuid); 89 | hasUpdated = true; 90 | break; 91 | } 92 | } 93 | 94 | if (!hasUpdated) { 95 | this.setAutoUpdate(false); 96 | } 97 | } 98 | } catch (e) { 99 | console.error(e); 100 | } 101 | 102 | if (this.autoUpdate) { 103 | setTimeout(loop, this.autoUpdateInterval); 104 | } 105 | }; 106 | loop(); 107 | } 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | module.exports = { 3 | // https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy 4 | // This option interrupts the configuration hierarchy at this file 5 | // Remove this if you have an higher level ESLint config file (it usually happens into a monorepos) 6 | root: true, 7 | 8 | // https://eslint.vuejs.org/user-guide/#how-to-use-custom-parser 9 | // Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working 10 | // `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted 11 | parserOptions: { 12 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser#configuration 13 | // https://github.com/TypeStrong/fork-ts-checker-webpack-plugin#eslint 14 | // Needed to make the parser take into account 'vue' files 15 | extraFileExtensions: ['.vue'], 16 | parser: '@typescript-eslint/parser', 17 | project: resolve(__dirname, './tsconfig.json'), 18 | tsconfigRootDir: __dirname, 19 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 20 | sourceType: 'module' // Allows for the use of imports 21 | }, 22 | 23 | env: { 24 | browser: true 25 | }, 26 | 27 | // Rules order is important, please avoid shuffling them 28 | extends: [ 29 | // Base ESLint recommended rules 30 | // 'eslint:recommended', 31 | 32 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage 33 | // ESLint typescript rules 34 | 'plugin:@typescript-eslint/recommended', 35 | // consider disabling this class of rules if linting takes too long 36 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 37 | 38 | // Uncomment any of the lines below to choose desired strictness, 39 | // but leave only one uncommented! 40 | // See https://eslint.vuejs.org/rules/#available-rules 41 | 'plugin:vue/essential', // Priority A: Essential (Error Prevention) 42 | 'plugin:vue/strongly-recommended', // Priority B: Strongly Recommended (Improving Readability) 43 | // 'plugin:vue/recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead) 44 | 45 | 'airbnb-base' 46 | 47 | ], 48 | 49 | plugins: [ 50 | // required to apply rules which need type information 51 | '@typescript-eslint', 52 | 53 | // https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file 54 | // required to lint *.vue files 55 | 'vue', 56 | 57 | ], 58 | 59 | globals: { 60 | ga: true, // Google Analytics 61 | cordova: true, 62 | __statics: true, 63 | process: true, 64 | Capacitor: true, 65 | chrome: true 66 | }, 67 | 68 | // add your custom rules here 69 | rules: { 70 | 'no-param-reassign': 'off', 71 | 'indent': ['warn', 4, { "SwitchCase": 1 }], 72 | 'no-void': 'off', 73 | 'no-multiple-empty-lines': 'warn', 74 | 'lines-between-class-members': 'off', 75 | "no-shadow": "off", 76 | 77 | 'import/first': 'off', 78 | 'import/named': 'error', 79 | 'import/namespace': 'error', 80 | 'import/default': 'error', 81 | 'import/export': 'error', 82 | 'import/extensions': 'off', 83 | 'import/no-unresolved': 'off', 84 | 'import/no-extraneous-dependencies': 'off', 85 | 'import/prefer-default-export': 'off', 86 | 'import/no-webpack-loader-syntax': 'off', 87 | 'prefer-promise-reject-errors': 'off', 88 | 'max-classes-per-file': 'off', 89 | 'class-methods-use-this': 'off', 90 | 'no-restricted-globals': 'warn', 91 | 92 | // TypeScript 93 | quotes: ['warn', 'single', { avoidEscape: true }], 94 | "@typescript-eslint/no-shadow": ["error"], 95 | '@typescript-eslint/ban-ts-comment': 'off', 96 | // '@typescript-eslint/explicit-function-return-type': 'off', 97 | // '@typescript-eslint/explicit-module-boundary-types': 'off', 98 | // '@typescript-eslint/no-unsafe-call': 'warn', 99 | // '@typescript-eslint/no-unsafe-member-access': 'off', 100 | // '@typescript-eslint/no-unsafe-assignment': 'off', 101 | 102 | // allow debugger during development only 103 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Language.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import Logger from './Logger'; 3 | import { SyncStorage } from './Storage'; 4 | import lang_enUS_data from './lang/en-US.json'; 5 | import lang_svSE_data from './lang/sv-SE.json'; 6 | import lang_frFR_data from './lang/fr-FR.json'; 7 | import lang_itIT_data from './lang/it-IT.json'; 8 | import lang_esES_data from './lang/es-ES.json'; 9 | import lang_deDE_data from './lang/de-DE.json'; 10 | import lang_ukUA_data from './lang/uk-UA.json'; 11 | // import lang_enSTEEF_data from './lang/en-STEEF.json'; 12 | import lang_euES_data from './lang/eu-ES.json'; 13 | import lang_glES_data from './lang/gl-ES.json'; 14 | import lang_ruRU_data from './lang/ru-RU.json'; 15 | import lang_nlBE_data from './lang/nl-BE.json'; 16 | import lang_ptBR_data from './lang/pt-BR.json'; 17 | 18 | export class Language { 19 | tag: string; 20 | name: string; 21 | data: { [key: string]: unknown } = {}; 22 | 23 | constructor(name: string, tag: string, data: { [key: string]: unknown; }) { 24 | this.tag = tag; 25 | this.name = name; 26 | this.data = data; 27 | } 28 | 29 | register(): void { 30 | Language.languages.push(this); 31 | } 32 | 33 | get(name: string, vars?: { [key: string]: unknown }): string { 34 | const keys = name.split(/\./g); 35 | let val: unknown = this.data; 36 | keys.forEach((key) => { 37 | val = (val as { [key: string]: unknown })[key] as ({ [key: string]: unknown } | string); 38 | }); 39 | 40 | if (vars !== undefined) { 41 | Object.keys(vars).forEach((variable) => { 42 | val = (val as string).split(`{{${variable}}}`).join(vars[variable] as string); 43 | }); 44 | } 45 | 46 | return val as string; 47 | } 48 | 49 | setDefault(): void { 50 | Language.default = this; 51 | } 52 | 53 | static languages: Language[] = []; 54 | static default: Language; 55 | static current: Language; 56 | static async load(): Promise { 57 | // Check for the first language that isn't equal to the default 58 | let language = await SyncStorage.LANGUAGE.get(); 59 | 60 | if (language === undefined || language === 'automatic') { 61 | language = this.automatic(); 62 | } 63 | 64 | Logger.info(Language.get('lang-config', { language })); 65 | this.languages.forEach((lang) => { 66 | if (lang.tag === language) { 67 | this.current = lang; 68 | } 69 | }); 70 | } 71 | 72 | static set(language: Language): void { 73 | this.current = language; 74 | } 75 | 76 | static automatic(): string | undefined { 77 | return window.navigator.languages.find( 78 | (l: string) => l.length >= 5 79 | && (this.default === undefined || l !== this.default.tag), 80 | ); 81 | } 82 | 83 | static init(): void { 84 | const lang_deDE = new Language('Deutsche (DE)', 'de-DE', lang_deDE_data); 85 | lang_deDE.register(); 86 | 87 | const lang_esES = new Language('Español (ES)', 'es-ES', lang_esES_data); 88 | lang_esES.register(); 89 | 90 | const lang_enUS = new Language('English (US)', 'en-US', lang_enUS_data); 91 | lang_enUS.register(); 92 | lang_enUS.setDefault(); 93 | 94 | // const lang_enSTEEF = new Language('English (Steef)', 'en-STEEF', lang_enSTEEF_data); 95 | // lang_enSTEEF.register(); 96 | 97 | const lang_frFR = new Language('Français (FR)', 'fr-FR', lang_frFR_data); 98 | lang_frFR.register(); 99 | 100 | const lang_itIT = new Language('Italiano (IT)', 'it-IT', lang_itIT_data); 101 | lang_itIT.register(); 102 | 103 | const lang_svSE = new Language('Svenska (SE)', 'sv-SE', lang_svSE_data); 104 | lang_svSE.register(); 105 | 106 | const lang_ukUA = new Language('Українська (UA)', 'uk-UA', lang_ukUA_data); 107 | lang_ukUA.register(); 108 | 109 | const lang_euES = new Language('Euskara (EU)', 'eu-ES', lang_euES_data); 110 | lang_euES.register(); 111 | 112 | const lang_glES = new Language('Galego (GL)', 'gl-ES', lang_glES_data); 113 | lang_glES.register(); 114 | 115 | const lang_ruRU = new Language('русский (RU)', 'ru-RU', lang_ruRU_data); 116 | lang_ruRU.register(); 117 | 118 | const lang_nlBE = new Language('Nederlands (BE)', 'nl-BE', lang_nlBE_data); 119 | lang_nlBE.register(); 120 | 121 | const lang_ptBR = new Language('Português (BR)', 'pt-BR', lang_ptBR_data); 122 | lang_ptBR.register(); 123 | } 124 | 125 | static get(name: string, vars?: { [key: string]: unknown }): string { 126 | if (this.current === undefined) { 127 | this.current = this.default; 128 | } 129 | const val = this.current.get(name, vars); 130 | 131 | return val; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/components/AllowWindowedMode.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../Component'; 2 | import Logger from '../Logger'; 3 | import { Language } from '../Language'; 4 | import { UIButton } from '../ui/UIButton'; 5 | import Util from '../util/Util'; 6 | 7 | /** 8 | * A button allowing users to play Stadia in windowed mode. 9 | * 10 | * @export the AllowWindowedMode type. 11 | * @class AllowWindowedMode 12 | * @extends {Component} 13 | */ 14 | export class AllowWindowedMode extends Component { 15 | /** 16 | * The component tag, used in language files. 17 | */ 18 | tag = 'allow-windowed-mode'; 19 | 20 | /** 21 | * The [[UIButton]] used to toggle windowed mode. 22 | */ 23 | button!: UIButton; 24 | 25 | /** 26 | * Whether windowed mode is enabled or not 27 | */ 28 | windowed = false; 29 | 30 | constructor() { 31 | super(); 32 | 33 | /** 34 | * Main event, stops built-in fullscreen events from reaching 35 | * Stadia whenever windowed mode is enabled. 36 | * */ 37 | window.addEventListener( 38 | 'fullscreenchange', 39 | (event: Event) => { 40 | if (this.windowed) { 41 | event.stopPropagation(); 42 | } 43 | }, 44 | true, 45 | ); 46 | } 47 | 48 | /** 49 | * Enters windowed mode. 50 | * 51 | * @memberof AllowWindowedMode 52 | */ 53 | enterWindowed(): void { 54 | this.windowed = true; 55 | void document.exitFullscreen(); 56 | } 57 | 58 | /** 59 | * Exits windowed mode 60 | * 61 | * @memberof AllowWindowedMode 62 | */ 63 | exitWindowed(): void { 64 | this.windowed = false; 65 | void document.documentElement.requestFullscreen(); 66 | } 67 | 68 | /** 69 | * Called on startup, initializes important variables. 70 | * 71 | * @memberof AllowWindowedMode 72 | */ 73 | onStart(): void { 74 | Logger.component( 75 | Language.get('component.enabled', { name: this.name }), 76 | ); 77 | this.active = true; 78 | 79 | const icon = chrome.runtime.getURL('images/icons/windowed.svg'); 80 | this.button = new UIButton( 81 | icon, 82 | Language.get('allow-windowed-mode.button-label.windowed'), 83 | this.id, 84 | ); 85 | } 86 | 87 | /** 88 | * Called on stop, makes sure to dispose of elements and variables. 89 | * 90 | * @memberof AllowWindowedMode 91 | */ 92 | onStop(): void { 93 | this.exitWindowed(); 94 | this.active = false; 95 | } 96 | 97 | /** 98 | * Update button labels and icons to fit current mode. 99 | * 100 | * @memberof AllowWindowedMode 101 | */ 102 | updateButton(): void { 103 | const icon = chrome.runtime.getURL('images/icons/windowed.svg'); 104 | const exitIcon = chrome.runtime.getURL( 105 | 'images/icons/windowed_exit.svg', 106 | ); 107 | 108 | if (this.windowed) { 109 | this.button.setIcon(exitIcon); 110 | this.button.setTitle( 111 | Language.get('allow-windowed-mode.button-label.fullscreen'), 112 | ); 113 | } else { 114 | this.button.setIcon(icon); 115 | this.button.setTitle( 116 | Language.get('allow-windowed-mode.button-label.windowed'), 117 | ); 118 | } 119 | } 120 | 121 | // Whether events have been added already or not. 122 | eventsAdded = false; 123 | 124 | /** 125 | * Called once every second, updates component elements and variables 126 | * 127 | * @memberof AllowWindowedMode 128 | */ 129 | onUpdate(): void { 130 | // If menu is open and a game is playing. 131 | if (Util.isMenuOpen() && Util.isInGame()) { 132 | // If the button doesn't already exist in the current renderer 133 | if (!this.exists()) { 134 | // Check for new renderers 135 | this.updateRenderer(); 136 | 137 | // Create the button instance 138 | this.button.create(() => { 139 | // If events are already added, don't add them again 140 | if (!this.eventsAdded) { 141 | this.button.onPressed(() => { 142 | if (this.windowed) { 143 | this.exitWindowed(); 144 | } else { 145 | this.enterWindowed(); 146 | } 147 | this.updateButton(); 148 | }); 149 | this.eventsAdded = true; 150 | } 151 | }); 152 | } 153 | 154 | if (!this.button.container?.exists()) { 155 | this.button.container?.create(); 156 | } 157 | } else if (this.existsAnywhere()) { 158 | this.button.destroy(); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/components/Ratings.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../Component'; 2 | import Logger from '../Logger'; 3 | import Util from '../util/Util'; 4 | import './styles/Ratings.scss'; 5 | import { Language } from '../Language'; 6 | import { StadiaGameDB } from '../StadiaGameDB'; 7 | 8 | /** 9 | * A component adding Metacritic ratings to every Stadia game. 10 | * 11 | * @export the Ratings type 12 | * @class Ratings 13 | * @extends {Component} 14 | */ 15 | export class Ratings extends Component { 16 | /** 17 | * The component tag, used in language files. 18 | */ 19 | tag = 'ratings'; 20 | 21 | /** 22 | * The rating element. 23 | */ 24 | element: HTMLElement | null = null; 25 | 26 | /** 27 | * The value from each bound in which a game will get 0 or 5 stars. 28 | */ 29 | graceAmount = 10; 30 | 31 | /** 32 | * The maximum number of stars to award. 33 | */ 34 | maxStars = 5; 35 | 36 | /** 37 | * Creates the rating element. 38 | * 39 | * @memberof Ratings 40 | */ 41 | createElement(): void { 42 | this.element = document.createElement('div'); 43 | this.element.classList.add('stadiaplus_rating', 'material-icons'); 44 | } 45 | 46 | /** 47 | * The current game UUID. 48 | * 49 | * @returns the game UUID as a string. 50 | * @memberof Ratings 51 | */ 52 | getUUID(): string { 53 | // eslint-disable-next-line no-restricted-globals 54 | return location.href.substring( 55 | 'https://stadia.google.com/store/details/'.length, 56 | 'https://stadia.google.com/store/details/'.length + 36, 57 | ); 58 | } 59 | 60 | /** 61 | * Updates the current rating, fetching it from the database. 62 | * 63 | * @memberof Ratings 64 | */ 65 | updateRating(): void { 66 | const uuid = this.getUUID(); 67 | const { rating } = StadiaGameDB.get(uuid); 68 | if (rating === undefined) return; 69 | 70 | this.element?.setAttribute('data-rating', rating.toString()); 71 | } 72 | 73 | /** 74 | * Calculates how many stars a game should have based on it's rating. 75 | * 76 | * @param {number} rating the game's rating. 77 | * @returns {string[]} an array of icon strings, being either "star", 78 | * "star_half" or "star_outline". 79 | * @memberof Ratings 80 | */ 81 | getStars(rating: number): string[] { 82 | const outputStars = []; 83 | 84 | // Clamps the rating to values between 0 and 1, 85 | // where (0 + graceAmount) is 0 and (100 - graceAmount) is 1 86 | const clampedR = (rating / 100) 87 | * (1 + (this.graceAmount / 100) * 2) 88 | - (this.graceAmount / 100); 89 | 90 | for (let i = 0, r = clampedR; i < this.maxStars; i += 1, r -= 1 / this.maxStars) { 91 | if (r >= 1 / this.maxStars) { 92 | outputStars.push('star'); 93 | } else if (r >= 0) { 94 | outputStars.push('star_half'); 95 | } else { 96 | outputStars.push('star_outline'); 97 | } 98 | } 99 | 100 | return outputStars; 101 | } 102 | 103 | /** 104 | * Called on startup, initializes important variables. 105 | * 106 | * @memberof Ratings 107 | */ 108 | onStart(): void { 109 | this.active = true; 110 | this.createElement(); 111 | if (this.element !== null) { 112 | this.element.id = this.id; 113 | } 114 | 115 | Logger.component(Language.get('component.enabled', { name: this.name })); 116 | } 117 | 118 | /** 119 | * Called on stop, makes sure to dispose of elements and variables. 120 | * 121 | * @memberof Ratings 122 | */ 123 | onStop(): void { 124 | this.active = false; 125 | this.element?.remove(); 126 | Logger.component(Language.get('component.disabled', { name: this.name })); 127 | } 128 | 129 | /** 130 | * Called every second, updates the rating element 131 | * to make sure it always displays the correct value. 132 | * 133 | * @memberof Ratings 134 | */ 135 | onUpdate(): void { 136 | if (Util.isInStoreDetail()) { 137 | if (!this.exists()) { 138 | this.updateRating(); 139 | this.updateRenderer(); 140 | const rating = parseInt(this.element?.getAttribute('data-rating') as string, 10); 141 | const stars = this.getStars(rating); 142 | 143 | if (rating > 0) { 144 | const nextSibling = this.renderer?.querySelector('.ZzBJSb > .BMUnfd'); 145 | if (nextSibling === null || this.element === null) return; 146 | 147 | nextSibling?.parentNode?.insertBefore(this.element, nextSibling); 148 | 149 | this.element.innerHTML = ` 150 | ${stars.join(' ')} 151 | 152 |
153 | ${rating} / 100 (${Language.get('ratings.source-name')}) 154 |
155 | `; 156 | } 157 | } 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/popup/src/components/Profile.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 110 | 111 | 175 | -------------------------------------------------------------------------------- /src/StadiaPlusDB.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-use-before-define */ 2 | import axios from 'axios'; 3 | import { LocalStorage } from './Storage'; 4 | import Logger from './Logger'; 5 | import { Language } from './Language'; 6 | 7 | export class StadiaPlusDB { 8 | static LFGConnector: LFGConnector; 9 | static ProfileConnector: ProfileConnector; 10 | 11 | static url: string; 12 | static authToken: string; 13 | static connected: boolean; 14 | 15 | static connect(url: string): Promise { 16 | Logger.info(Language.get('stadiaplusdb.connecting', { url })); 17 | StadiaPlusDB.url = url; 18 | StadiaPlusDB.LFGConnector = new LFGConnector(); 19 | StadiaPlusDB.ProfileConnector = new ProfileConnector(); 20 | 21 | return new Promise((resolve) => { 22 | void this.testConnection() 23 | .then((connected) => { 24 | StadiaPlusDB.connected = connected; 25 | 26 | resolve(connected); 27 | }); 28 | }); 29 | } 30 | 31 | static testConnection(): Promise { 32 | return new Promise((resolve) => { 33 | axios.get(`${StadiaPlusDB.url}/api/ping`) 34 | .then(() => resolve(true)) 35 | .catch(() => resolve(false)); 36 | }); 37 | } 38 | 39 | static getProfile(): Promise { 40 | return new Promise((resolve, reject) => { 41 | axios.get(`${StadiaPlusDB.url}/api/user?token=${StadiaPlusDB.authToken}`) 42 | .then((res) => { 43 | if (Object.prototype.hasOwnProperty.call(res.data, 'error')) { 44 | reject(res.data); 45 | return; 46 | } 47 | resolve(res.data); 48 | }) 49 | .catch(() => reject({ error: 'Could not connect to profile server' })); 50 | }); 51 | } 52 | 53 | static isConnected(): boolean { 54 | return StadiaPlusDB.connected && StadiaPlusDB.url != null; 55 | } 56 | 57 | static isAuthenticated(): boolean { 58 | return StadiaPlusDB.isConnected() && StadiaPlusDB.authToken != null; 59 | } 60 | 61 | static authenticate(): Promise { 62 | return new Promise((resolve, reject) => { 63 | if (!StadiaPlusDB.isConnected()) { 64 | reject({ 65 | error: 'Not connected to any database', 66 | }); 67 | } 68 | 69 | chrome.identity.launchWebAuthFlow( 70 | { 71 | url: `${StadiaPlusDB.url}/auth/google?redirect=${chrome.identity.getRedirectURL('database')}`, 72 | interactive: true, 73 | }, 74 | (responseUrl?: string) => { 75 | if (responseUrl == null) { 76 | reject({ 77 | error: 'Authentication failed', 78 | }); 79 | return; 80 | } 81 | 82 | const url = new URL(responseUrl); 83 | StadiaPlusDB.authToken = url.hash.substring(1); 84 | void LocalStorage.AUTH_TOKEN.set(StadiaPlusDB.authToken) 85 | .then(() => resolve(StadiaPlusDB.authToken)); 86 | }, 87 | ); 88 | }); 89 | } 90 | 91 | static signout(): Promise { 92 | return axios({ 93 | method: 'post', 94 | url: `${StadiaPlusDB.url}/api/signout`, 95 | data: { 96 | token: StadiaPlusDB.authToken, 97 | }, 98 | }); 99 | } 100 | 101 | static wipedata(): Promise { 102 | return axios({ 103 | method: 'post', 104 | url: `${StadiaPlusDB.url}/api/wipedata`, 105 | data: { 106 | token: StadiaPlusDB.authToken, 107 | }, 108 | }); 109 | } 110 | } 111 | 112 | export class LFGConnector { 113 | get(game: string): Promise { 114 | return axios.get(`${StadiaPlusDB.url}/api/lfg/${game}`); 115 | } 116 | 117 | post(game: string): Promise { 118 | if (!StadiaPlusDB.isConnected()) { 119 | return new Promise((resolve, reject) => reject({ error: 'Not connected to the StadiaPlusDB database' })); 120 | } 121 | if (!StadiaPlusDB.isAuthenticated()) { 122 | return new Promise((resolve, reject) => reject({ error: 'Not authenticated with StadiaPlusDB' })); 123 | } 124 | return axios({ 125 | method: 'post', 126 | url: `${StadiaPlusDB.url}/api/lfg`, 127 | data: { 128 | token: StadiaPlusDB.authToken, 129 | game, 130 | }, 131 | }); 132 | } 133 | } 134 | 135 | export class ProfileConnector { 136 | async setData(data: unknown): Promise { 137 | if (!StadiaPlusDB.isConnected()) { 138 | return new Promise((resolve, reject) => reject({ error: 'Not connected to the StadiaPlusDB database' })); 139 | } 140 | if (!StadiaPlusDB.isAuthenticated()) { 141 | return new Promise((resolve, reject) => reject({ error: 'Not authenticated with StadiaPlusDB' })); 142 | } 143 | 144 | return axios({ 145 | method: 'post', 146 | url: `${StadiaPlusDB.url}/api/update`, 147 | data: { 148 | token: StadiaPlusDB.authToken, 149 | game: data, 150 | }, 151 | }); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/lang/gl-ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Creado por {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "A extensión está lista. Inicia Stadia e lume! 🎮", 9 | "launch-button": "Inicia Stadia", 10 | "settings-button": "Configuración", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Axuda & FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Configuración", 42 | "language": "Idioma", 43 | "components": "Compoñentes", 44 | "edit-components": "Edita os compoñentes" 45 | }, 46 | "developer-page": { 47 | "title": "Avanzadas", 48 | "clear-cache-button": "Limpa a caché", 49 | "storage": "Almacenamento" 50 | }, 51 | "component-page": { 52 | "title": "Compoñentes" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "Activouse {{name}}.", 57 | "disabled": "Desactivouse {{name}}." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Permite o modo fiestra", 61 | "button-label": { 62 | "windowed": "En fiestra", 63 | "fullscreen": "Pantalla completa" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Reloxo" 68 | }, 69 | "force-codec": { 70 | "name": "Forza códec", 71 | "4k-tooltip": "O códec seleccionado non está disponíbel ao seleccionar 4K" 72 | }, 73 | "force-resolution": { 74 | "name": "Forza resolución", 75 | "note": "Aviso: o valor indicado será o máximo que Stadia intentará acadar. Se o teu computador non é compatíbel coa resolución ou esta supera o uso de datos que teñas configurado na conta, non se activará." 76 | }, 77 | "library-filter": { 78 | "name": "Filtrar", 79 | "recent": "Recentes", 80 | "alphabetical": "Alfabeticamente", 81 | "random": "Aleatoriamente", 82 | "show-hidden": "Mostrar agochados", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Monitor de rede", 94 | "heading-visible": "Estatísticas", 95 | "button-label": "Monitor", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "paste-from-clipboard": { 115 | "name": "Pegar do portapapeis" 116 | }, 117 | "ratings": { 118 | "name": "Valoracións", 119 | "source-name": "Metacritic" 120 | }, 121 | "store-filter": { 122 | "name": "Filtrar" 123 | }, 124 | "ui-tab": { 125 | "name": "Pestaña UI de Stadia+", 126 | "button-label": "Stadia+" 127 | }, 128 | "popup-fix": { 129 | "name": "Popup Fix" 130 | }, 131 | "looking-for-group": { 132 | "name": "Looking For Group", 133 | "toggle-button": { 134 | "start": "Look for a Group", 135 | "stop": "Stop Looking for Groups" 136 | } 137 | }, 138 | "stadiaplusdb": { 139 | "name": "Stadia+ DB", 140 | "updating": "Updating {{game}} in Stadia+ DB", 141 | "connecting": "Connecting to Stadia+ DB via {{url}}", 142 | "signed-in": "Logged into Stadia+ DB as {{user}}" 143 | }, 144 | "snackbar": { 145 | "reload-to-update": "Recarga a páxina para activar os cambios.", 146 | "hide-game": "Agochóuse un xogo.", 147 | "show-game": "Un xogo deixou de estar agochado." 148 | }, 149 | "automatic": "Automático", 150 | "vp9": "VP9", 151 | "h264": "H264", 152 | "apply": "Aplicar", 153 | "loading": "Loading...", 154 | "experimental": "Experimental", 155 | "4k": "4K", 156 | "1440p": "1440p", 157 | "1080p": "1080p", 158 | "720p": "720p", 159 | "lang-config": "Using language configuration {{language}}" 160 | } 161 | -------------------------------------------------------------------------------- /src/lang/nl-BE.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Ontwikkeld door {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "De extentie is klaar. Je kan nu Stadia openen en beginnen spelen! 🎮", 9 | "launch-button": "Stadia starten", 10 | "settings-button": "Instellingen", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Help & FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Instellingen", 42 | "language": "Taal", 43 | "components": "Componenten", 44 | "edit-components": "Componenten aanpassen" 45 | }, 46 | "developer-page": { 47 | "title": "Ontwikkelaar", 48 | "clear-cache-button": "Cache wissen", 49 | "storage": "Opslag" 50 | }, 51 | "component-page": { 52 | "title": "Componenten" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "Component {{name}} werd ingeschakeld.", 57 | "disabled": "Component {{name}} werd uitgeschakeld." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Spelen in venster toestaan", 61 | "button-label": { 62 | "windowed": "In venster", 63 | "fullscreen": "Fullscreen" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Klok" 68 | }, 69 | "force-codec": { 70 | "name": "Codec forceren", 71 | "4k-tooltip": "Codec kan niet geforceerd worden wanneer je in 4K of 1440p speelt" 72 | }, 73 | "force-resolution": { 74 | "name": "Resolutie forceren", 75 | "note": "Merk op: Deze waarde is de maximale resolutie dat Stadia zal proberen te halen. Dit zal niet werken als je computer deze resolutie niet kan afspelen of als de resolutie niet beschikbaar is voor de dataverbruik selectie." 76 | }, 77 | "library-filter": { 78 | "name": "Bibliotheek Filter", 79 | "recent": "Recent", 80 | "alphabetical": "Alfabetisch", 81 | "random": "Willekeurig", 82 | "show-hidden": "Verborgen weergeven", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Netwerk Monitor", 94 | "heading-visible": "Zichtbare Stats", 95 | "button-label": "Monitor", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "ratings": { 115 | "name": "Scores", 116 | "source-name": "Metacritic" 117 | }, 118 | "store-filter": { 119 | "name": "Filter bewaren" 120 | }, 121 | "ui-tab": { 122 | "name": "Stadia+ UI Tab", 123 | "button-label": "Stadia+" 124 | }, 125 | "popup-fix": { 126 | "name": "Popup Fix" 127 | }, 128 | "looking-for-group": { 129 | "name": "Looking For Group", 130 | "toggle-button": { 131 | "start": "Look for a Group", 132 | "stop": "Stop Looking for Groups" 133 | } 134 | }, 135 | "stadiaplusdb": { 136 | "name": "Stadia+ DB", 137 | "updating": "Updating {{game}} in Stadia+ DB", 138 | "connecting": "Connecting to Stadia+ DB via {{url}}", 139 | "signed-in": "Logged into Stadia+ DB as {{user}}" 140 | }, 141 | "snackbar": { 142 | "reload-to-update": "Herlaad de pagina om je aanpassingen te zien.", 143 | "hide-game": "Een spel werd verborgen.", 144 | "show-game": "Een spel is niet langer verborgen." 145 | }, 146 | "automatic": "Automatisch", 147 | "vp9": "VP9", 148 | "h264": "H264", 149 | "apply": "Toepassen", 150 | "loading": "Loading...", 151 | "experimental": "Experimental", 152 | "4k": "4K", 153 | "1440p": "1440p", 154 | "1080p": "1080p", 155 | "720p": "720p", 156 | "lang-config": "Using language configuration {{language}}" 157 | } 158 | -------------------------------------------------------------------------------- /src/lang/eu-ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Egilea: {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "Luzapena prest dago. Ireki Stadia eta hasi jolasten! Egurra! 🎮", 9 | "launch-button": "Ireki Stadia", 10 | "settings-button": "Ezarpenak", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Laguntza & FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Ezarpenak", 42 | "language": "Hizkuntza", 43 | "components": "Osagarriak", 44 | "edit-components": "Editatu osagarriak" 45 | }, 46 | "developer-page": { 47 | "title": "Aurreratuak", 48 | "clear-cache-button": "Cachea ezabatu", 49 | "storage": "Biltegia" 50 | }, 51 | "component-page": { 52 | "title": "Osagarriak" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "{{name}} gaitu egin da.", 57 | "disabled": "{{name}} desgaitu egin da." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Onartu lehio-modua", 61 | "button-label": { 62 | "windowed": "Lehioan", 63 | "fullscreen": "Pantaila osoa" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Erlojua" 68 | }, 69 | "force-codec": { 70 | "name": "Behartu codec", 71 | "4k-tooltip": "Codec hori ezin da gaitu 4K aukerarekin" 72 | }, 73 | "force-resolution": { 74 | "name": "Behartu bereizmena", 75 | "note": "Oharra: jarritako balioa izango da Stadia ezartzen saiatuko den handiena. Zure ordenagailuak ezin badu bereizmen hori exekutatu edota ez badator bat Stadiaren zure data-ezarpenekin ezin izango da gauzatu.." 76 | }, 77 | "library-filter": { 78 | "name": "Bildumaren iragazkia", 79 | "recent": "Arestikoak", 80 | "alphabetical": "Alfabetikoa", 81 | "random": "Aliritzira", 82 | "show-hidden": "Erakutsi izkutatutakoak", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Sare-monitorea", 94 | "heading-visible": "Ikusteko estatistikak", 95 | "button-label": "Monitorea", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "paste-from-clipboard": { 115 | "name": "Itsatsi arbelean" 116 | }, 117 | "ratings": { 118 | "name": "Balorazioak", 119 | "source-name": "Metacritic" 120 | }, 121 | "store-filter": { 122 | "name": "Denda-iragazkia" 123 | }, 124 | "ui-tab": { 125 | "name": "Stadia+ UI Fitxa", 126 | "button-label": "Stadia+" 127 | }, 128 | "popup-fix": { 129 | "name": "Popup Fix" 130 | }, 131 | "looking-for-group": { 132 | "name": "Looking For Group", 133 | "toggle-button": { 134 | "start": "Look for a Group", 135 | "stop": "Stop Looking for Groups" 136 | } 137 | }, 138 | "stadiaplusdb": { 139 | "name": "Stadia+ DB", 140 | "updating": "Updating {{game}} in Stadia+ DB", 141 | "connecting": "Connecting to Stadia+ DB via {{url}}", 142 | "signed-in": "Logged into Stadia+ DB as {{user}}" 143 | }, 144 | "snackbar": { 145 | "reload-to-update": "Orria birkargatu ezarritako aldaketak abiarazteko.", 146 | "hide-game": "Joku bat izkutatu egin da.", 147 | "show-game": "Joku bat jada ikusgai dago." 148 | }, 149 | "automatic": "Automatikoa", 150 | "vp9": "VP9", 151 | "h264": "H264", 152 | "apply": "Ezarri", 153 | "loading": "Loading...", 154 | "experimental": "Experimental", 155 | "4k": "4K", 156 | "1440p": "1440p", 157 | "1080p": "1080p", 158 | "720p": "720p", 159 | "lang-config": "Using language configuration {{language}}" 160 | } 161 | -------------------------------------------------------------------------------- /src/lang/ru-RU.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Разработчик: {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "Расширение готово к работе. Запускай Stadia и начинай играть! 🎮", 9 | "launch-button": "Запустить Stadia", 10 | "settings-button": "Настройки", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Справка и FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Настройки", 42 | "language": "Язык", 43 | "components": "Components", 44 | "edit-components": "Edit Components" 45 | }, 46 | "developer-page": { 47 | "title": "Developer", 48 | "clear-cache-button": "Clear Cache", 49 | "storage": "Storage" 50 | }, 51 | "component-page": { 52 | "title": "Components" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "Элемент {{name}} включён.", 57 | "disabled": "Элемент {{name}} отключен." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Allow Windowed Mode", 61 | "button-label": { 62 | "windowed": "Windowed", 63 | "fullscreen": "Fullscreen" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Часы" 68 | }, 69 | "force-codec": { 70 | "name": "Принудительный запуск кодека", 71 | "4k-tooltip": "Forced Codec is not available when running in 4K or 1440p" 72 | }, 73 | "force-resolution": { 74 | "name": "Принудительная смена разрешения", 75 | "note": "Примечание: установливаемое значение - это максимальное разрешение, которое Stadia может достичь. Если ваш компьютер не может отобразить разрешение или оно не доступно с текущей скоростью передачи данных, оно не будет отображаться." 76 | }, 77 | "library-filter": { 78 | "name": "Фильтр", 79 | "recent": "Последние", 80 | "alphabetical": "Алфавитный", 81 | "random": "Случайные", 82 | "show-hidden": "Показать скрытые", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Мониторинг сети", 94 | "heading-visible": "Видимая статистика", 95 | "button-label": "Монитор", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "paste-from-clipboard": { 115 | "name": "Вставить из буфера обмена" 116 | }, 117 | "ratings": { 118 | "name": "Рейтинги", 119 | "source-name": "Metacritic" 120 | }, 121 | "store-filter": { 122 | "name": "Сохранить фильтр" 123 | }, 124 | "ui-tab": { 125 | "name": "Stadia+ UI Вкладка", 126 | "button-label": "Stadia+" 127 | }, 128 | "popup-fix": { 129 | "name": "Popup Fix" 130 | }, 131 | "looking-for-group": { 132 | "name": "Looking For Group", 133 | "toggle-button": { 134 | "start": "Look for a Group", 135 | "stop": "Stop Looking for Groups" 136 | } 137 | }, 138 | "stadiaplusdb": { 139 | "name": "Stadia+ DB", 140 | "updating": "Updating {{game}} in Stadia+ DB", 141 | "connecting": "Connecting to Stadia+ DB via {{url}}", 142 | "signed-in": "Logged into Stadia+ DB as {{user}}" 143 | }, 144 | "snackbar": { 145 | "reload-to-update": "Перезагрузите страницу, чтобы увидеть изменения.", 146 | "hide-game": "Игра скрыта.", 147 | "show-game": "Игра теперь отображается." 148 | }, 149 | "automatic": "Автоматически", 150 | "vp9": "VP9", 151 | "h264": "H264", 152 | "apply": "Apply", 153 | "loading": "Loading...", 154 | "experimental": "Experimental", 155 | "4k": "4K", 156 | "1440p": "1440p", 157 | "1080p": "1080p", 158 | "720p": "720p", 159 | "lang-config": "Using language configuration {{language}}" 160 | } 161 | -------------------------------------------------------------------------------- /src/lang/de-DE.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Developed by {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "The extension is all ready to go. Just fire up Stadia and start playing! 🎮", 9 | "launch-button": "Launch Stadia", 10 | "settings-button": "Settings", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Help & FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Settings", 42 | "language": "Language", 43 | "components": "Components", 44 | "edit-components": "Edit Components" 45 | }, 46 | "developer-page": { 47 | "title": "Developer", 48 | "clear-cache-button": "Clear Cache", 49 | "storage": "Storage" 50 | }, 51 | "component-page": { 52 | "title": "Components" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "Komponente {{name}} wurde aktiviert.", 57 | "disabled": "Komponente {{name}} wurde deaktiviert." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Allow Windowed Mode", 61 | "button-label": { 62 | "windowed": "Windowed", 63 | "fullscreen": "Fullscreen" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Uhr" 68 | }, 69 | "force-codec": { 70 | "name": "Codec erzwingen", 71 | "4k-tooltip": "Forced Codec is not available when running in 4K or 1440p" 72 | }, 73 | "force-resolution": { 74 | "name": "Auflösung erzwingen", 75 | "note": "Anmerkung: Die gewählte Auflösung ist die Maximale, die Stadia verweden wird. Falls Ihr Computer diese Auflösung nicht darstellen kann oder nicht genug Bandbreite zu Verfügung steht, wird eine kleinere Auflösung verwendet." 76 | }, 77 | "library-filter": { 78 | "name": "Sammlungsfilter", 79 | "recent": "Neuste", 80 | "alphabetical": "Alphabetisch", 81 | "random": "Zufällig", 82 | "show-hidden": "Zeige Versteckte", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Netwerkmonitor", 94 | "heading-visible": "Sichtbare Statistiken", 95 | "button-label": "Monitor", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "paste-from-clipboard": { 115 | "name": "Einfügen aus der Zwischenablage" 116 | }, 117 | "ratings": { 118 | "name": "Bewertungen", 119 | "source-name": "Metacritic" 120 | }, 121 | "store-filter": { 122 | "name": "Store Filter" 123 | }, 124 | "ui-tab": { 125 | "name": "Stadia+ UI Tab", 126 | "button-label": "Stadia+" 127 | }, 128 | "popup-fix": { 129 | "name": "Popup Fix" 130 | }, 131 | "looking-for-group": { 132 | "name": "Looking For Group", 133 | "toggle-button": { 134 | "start": "Look for a Group", 135 | "stop": "Stop Looking for Groups" 136 | } 137 | }, 138 | "stadiaplusdb": { 139 | "name": "Stadia+ DB", 140 | "updating": "Updating {{game}} in Stadia+ DB", 141 | "connecting": "Connecting to Stadia+ DB via {{url}}", 142 | "signed-in": "Logged into Stadia+ DB as {{user}}" 143 | }, 144 | "snackbar": { 145 | "reload-to-update": "Seite neu laden um die Änderung anzuzeigen.", 146 | "hide-game": "Ein Spiel wurde versteckt.", 147 | "show-game": "Ein Spiel ist nicht mehr versteckt." 148 | }, 149 | "automatic": "Automatisch", 150 | "vp9": "VP9", 151 | "h264": "H264", 152 | "apply": "Anwenden", 153 | "loading": "Loading...", 154 | "experimental": "Experimental", 155 | "4k": "4K", 156 | "1440p": "1440p", 157 | "1080p": "1080p", 158 | "720p": "720p", 159 | "lang-config": "Using language configuration {{language}}" 160 | } 161 | -------------------------------------------------------------------------------- /src/lang/es-ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Developed by {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "The extension is all ready to go. Just fire up Stadia and start playing! 🎮", 9 | "launch-button": "Launch Stadia", 10 | "settings-button": "Settings", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Help & FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Settings", 42 | "language": "Language", 43 | "components": "Components", 44 | "edit-components": "Edit Components" 45 | }, 46 | "developer-page": { 47 | "title": "Developer", 48 | "clear-cache-button": "Clear Cache", 49 | "storage": "Storage" 50 | }, 51 | "component-page": { 52 | "title": "Components" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "El componente {{name}} ha sido habilitado.", 57 | "disabled": "El componente {{name}} ha sido deshabilitado." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Allow Windowed Mode", 61 | "button-label": { 62 | "windowed": "Windowed", 63 | "fullscreen": "Fullscreen" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Reloj" 68 | }, 69 | "force-codec": { 70 | "name": "Fuerza Códec", 71 | "4k-tooltip": "Forced Codec is not available when running in 4K or 1440p" 72 | }, 73 | "force-resolution": { 74 | "name": "Fuerza Resolución", 75 | "note": "Nota: el valor establecido es la resolución máxima que Stadia intentará lograr. Si su computadora no es capaz de procesar la resolución o no está disponible con la opción de uso de datos actual, no se mostrará." 76 | }, 77 | "library-filter": { 78 | "name": "Filtro Biblioteca", 79 | "recent": "Reciente", 80 | "alphabetical": "Alfabético", 81 | "random": "Aleatorio", 82 | "show-hidden": "Mostrar oculto", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Monitor de red", 94 | "heading-visible": "Estadísticas visibles", 95 | "button-label": "Monitor", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "paste-from-clipboard": { 115 | "name": "Pegar desde el portapapeles" 116 | }, 117 | "ratings": { 118 | "name": "Calificaciones", 119 | "source-name": "Metacrítico" 120 | }, 121 | "store-filter": { 122 | "name": "Filtro de tienda" 123 | }, 124 | "ui-tab": { 125 | "name": "Stadia+ UI Tab", 126 | "button-label": "Stadia+" 127 | }, 128 | "popup-fix": { 129 | "name": "Popup Fix" 130 | }, 131 | "looking-for-group": { 132 | "name": "Looking For Group", 133 | "toggle-button": { 134 | "start": "Look for a Group", 135 | "stop": "Stop Looking for Groups" 136 | } 137 | }, 138 | "stadiaplusdb": { 139 | "name": "Stadia+ DB", 140 | "updating": "Updating {{game}} in Stadia+ DB", 141 | "connecting": "Connecting to Stadia+ DB via {{url}}", 142 | "signed-in": "Logged into Stadia+ DB as {{user}}" 143 | }, 144 | "snackbar": { 145 | "reload-to-update": "Vuelva a cargar la página para ver sus cambios.", 146 | "hide-game": "Un juego ha sido escondido.", 147 | "show-game": "Un juego ya no está oculto." 148 | }, 149 | "automatic": "Automático", 150 | "vp9": "VP9", 151 | "h264": "H264", 152 | "apply": "Aplicar", 153 | "loading": "Loading...", 154 | "experimental": "Experimental", 155 | "4k": "4K", 156 | "1440p": "1440p", 157 | "1080p": "1080p", 158 | "720p": "720p", 159 | "lang-config": "Using language configuration {{language}}" 160 | } 161 | -------------------------------------------------------------------------------- /src/lang/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "popup": { 3 | "footer": { 4 | "credit": "Developed by {{name}}" 5 | }, 6 | "main-page": { 7 | "title": "Stadia+", 8 | "ready-text": "The extension is all ready to go. Just fire up Stadia and start playing! 🎮", 9 | "launch-button": "Launch Stadia", 10 | "settings-button": "Settings", 11 | "patreon-button": "Support on Patreon", 12 | "help-and-faq": "Help & FAQ", 13 | "discord": "Discord", 14 | "reddit": "Reddit", 15 | "github": "Github", 16 | "profile": { 17 | "wipe-data": "Delete user data", 18 | "view-profile": "View profile", 19 | "not-available": "Your profile is not yet available, please check back again after you've played a game or two.", 20 | "heading": "Keep track of your progress", 21 | "text": "Sign in to Stadia+ DB to automagically sync all your achievements and stats to your profile.", 22 | "more": "More about Stadia+ DB", 23 | "login-button":"Sign in with Google", 24 | "sign-out":"Sign out" 25 | } 26 | }, 27 | "user-page": { 28 | "title": "Login", 29 | "login-button": "Sign in with Google", 30 | "accept-label": "By signing into Stadia+ DB you accept the", 31 | "privacy-policy": "privacy policy" 32 | }, 33 | "wipe-data-page": { 34 | "title": "Wipe Data", 35 | "heading": "Really wipe your data?", 36 | "text": "Wiping your user data from Stadia+ DB means your profile will no longer be available, and your data will be completely gone from the Stadia+ DB database.

This does not effect your Stadia profile, and all your game data will still be available in Stadia.", 37 | "confirm": "Yes, wipe my data", 38 | "cancel": "No, I changed my mind" 39 | }, 40 | "settings-page": { 41 | "title": "Settings", 42 | "language": "Language", 43 | "components": "Components", 44 | "edit-components": "Edit Components" 45 | }, 46 | "developer-page": { 47 | "title": "Developer", 48 | "clear-cache-button": "Clear Cache", 49 | "storage": "Storage" 50 | }, 51 | "component-page": { 52 | "title": "Components" 53 | } 54 | }, 55 | "component": { 56 | "enabled": "Component {{name}} has been enabled.", 57 | "disabled": "Component {{name}} has been disabled." 58 | }, 59 | "allow-windowed-mode": { 60 | "name": "Allow Windowed Mode", 61 | "button-label": { 62 | "windowed": "Windowed", 63 | "fullscreen": "Fullscreen" 64 | } 65 | }, 66 | "clock": { 67 | "name": "Clock" 68 | }, 69 | "force-codec": { 70 | "name": "Force Codec", 71 | "4k-tooltip": "Forced Codec is not available when running in 4K or 1440p" 72 | }, 73 | "force-resolution": { 74 | "name": "Force Resolution", 75 | "note": "Note: the set value is the maximum resolution Stadia will attempt to achieve. If your computer is not capable of rendering the resolution or it is not available with the current data usage option, it will not be displayed." 76 | }, 77 | "library-filter": { 78 | "name": "Library Filter", 79 | "recent": "Recent", 80 | "alphabetical": "Alphabetical", 81 | "random": "Random", 82 | "show-hidden": "Show Hidden", 83 | "get-shortcut": "Get Desktop Shortcut", 84 | "all-visible": "All", 85 | "custom-visible": "Custom", 86 | "your-games": "Your Games", 87 | "your-captures": "Your Captures", 88 | "captures-note": "Your captures are now at the top! Look for the photo_camera icon in the navbar.", 89 | "hide-game": "Hide {{name}}", 90 | "show-game": "Show {{name}}" 91 | }, 92 | "network-monitor": { 93 | "name": "Network Monitor", 94 | "heading-visible": "Visible Stats", 95 | "button-label": "Monitor", 96 | "toggle-button": { 97 | "show": "Show Network Monitor", 98 | "hide": "Hide Network Monitor" 99 | }, 100 | "stats": { 101 | "time": "Time", 102 | "resolution": "Resolution", 103 | "fps": "FPS", 104 | "latency": "Latency", 105 | "codec": "Codec", 106 | "traffic": "Traffic", 107 | "current-traffic": "Current Traffic", 108 | "average-traffic": "Average Traffic", 109 | "packets-lost": "Packets Lost", 110 | "average-packet-loss": "Average Packet Loss", 111 | "jitter-buffer": "Jitter Buffer" 112 | } 113 | }, 114 | "paste-from-clipboard": { 115 | "name": "Paste from Clipboard" 116 | }, 117 | "ratings": { 118 | "name": "Ratings", 119 | "source-name": "Metacritic" 120 | }, 121 | "store-filter": { 122 | "name": "Store Filter" 123 | }, 124 | "ui-tab": { 125 | "name": "Stadia+ UI Tab", 126 | "button-label": "Stadia+" 127 | }, 128 | "popup-fix": { 129 | "name": "Popup Fix" 130 | }, 131 | "looking-for-group": { 132 | "name": "Looking For Group", 133 | "toggle-button": { 134 | "start": "Look for a Group", 135 | "stop": "Stop Looking for Groups" 136 | } 137 | }, 138 | "stadiaplusdb": { 139 | "name": "Stadia+ DB", 140 | "updating": "Updating {{game}} in Stadia+ DB", 141 | "connecting": "Connecting to Stadia+ DB via {{url}}", 142 | "signed-in": "Logged into Stadia+ DB as {{user}}" 143 | }, 144 | "snackbar": { 145 | "reload-to-update": "Reload the page to see your changes.", 146 | "hide-game": "A game has been hidden.", 147 | "show-game": "A game is no longer hidden." 148 | }, 149 | "profile": { 150 | "name": "Profile" 151 | }, 152 | "automatic": "Automatic", 153 | "vp9": "VP9", 154 | "h264": "H264", 155 | "apply": "Apply", 156 | "loading": "Loading...", 157 | "experimental": "Experimental", 158 | "4k": "4K", 159 | "1440p": "1440p", 160 | "1080p": "1080p", 161 | "720p": "720p", 162 | "lang-config": "Using language configuration {{language}}" 163 | } 164 | --------------------------------------------------------------------------------