New folder structure for extension
Separated client- and server-side features into seperate root folders. Since Stadia Avatar now has two versions (Userscript and Chrome extension). Added core extension functionality. Created a page constructor for extension popup. High probability that I will create a seperate repo for this feature, as it's pretty neat and very useful for future extensions.
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.crx
|
||||
*.pem
|
18
client/extension/_locales/en/messages.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extension_description": {
|
||||
"message": "Custom Stadia avatars for you and your friends",
|
||||
"description": "Short summary of this extension's function."
|
||||
},
|
||||
"avatar_set": {
|
||||
"message": "Set avatar from",
|
||||
"description": "List of different options to set a users's avatar"
|
||||
},
|
||||
"avatar_set_url_support": {
|
||||
"message": "All image formats supported by Chrome except SVG can be added",
|
||||
"description": "Disclaimer about supported image formats"
|
||||
},
|
||||
"page_return": {
|
||||
"message": "Go back",
|
||||
"description": "Tooltip when hovering the back button on a page"
|
||||
}
|
||||
}
|
163
client/extension/assets/css/popup.css
Normal file
|
@ -0,0 +1,163 @@
|
|||
:root {
|
||||
/* color components */
|
||||
--palette-background: 33,33,33;
|
||||
--palette-contrast: 255,255,255;
|
||||
--palette-header: 45,45,45;
|
||||
--palette-accent-high: 253,74,24;
|
||||
--palette-accent-low: 170,3,88;
|
||||
|
||||
/* compiled colors */
|
||||
--color-background: rgb(var(--palette-background));
|
||||
--color-contrast: rgb(var(--palette-contrast));
|
||||
--color-header: rgb(var(--palette-header));
|
||||
--color-accent: linear-gradient(145deg, var(--palette-accent-high) 0%, var(--palette-accent-low) 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body {
|
||||
--page-depth: 0;
|
||||
--animation-speed: 400ms;
|
||||
|
||||
width: 400px;
|
||||
transition: var(--animation-speed) transform cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: translateX(calc((100% * var(--page-depth)) * -1));
|
||||
}
|
||||
|
||||
.page .header,
|
||||
header {
|
||||
width: 100%;
|
||||
background-color: var(--color-header);
|
||||
border-bottom: solid 1px black;
|
||||
}
|
||||
|
||||
header {
|
||||
--header-padding: 30px;
|
||||
--pfp-size: 80px;
|
||||
--pfp-stroke-offset: 5px;
|
||||
--pfp-stroke-width: 4px;
|
||||
--pfp-stroke-size: calc(var(--pfp-stroke-offset) + var(--pfp-stroke-width));
|
||||
|
||||
height: calc(var(--pfp-size) + (var(--pfp-stroke-size) * 2));
|
||||
padding: var(--header-padding) 0 var(--header-padding) 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#myAvatar {
|
||||
width: var(--pfp-size);
|
||||
height: var(--pfp-size);
|
||||
box-shadow:
|
||||
0 0 0 var(--pfp-stroke-offset) var(--color-header),
|
||||
0 0 0 var(--pfp-stroke-size) rgb(var(--palette-accent-high));
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: none;
|
||||
width: 100%;
|
||||
font-size: 15px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ol li {
|
||||
--padding: 25px;
|
||||
|
||||
width: calc(100% - (var(--padding) * 2));
|
||||
height: 64px;
|
||||
padding: 0 var(--padding) 0 var(--padding);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page .back:hover,
|
||||
ol li:hover {
|
||||
background: rgba(var(--palette-contrast),.1);
|
||||
}
|
||||
|
||||
ol li img {
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
/* ---- */
|
||||
|
||||
.page {
|
||||
transition: var(--animation-speed) opacity;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.page .header {
|
||||
--height: 60px;
|
||||
--button-size: 40px;
|
||||
--padding: 10px;
|
||||
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--padding) 0 var(--padding);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page .back {
|
||||
--chevron-size: 10px;
|
||||
--chevron-stroke-width: 3px;
|
||||
|
||||
width: var(--button-size);
|
||||
height: var(--button-size);
|
||||
border-radius: 4px;
|
||||
border: solid 1px var(--color-background);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page .back::before {
|
||||
content: '';
|
||||
border-style: solid;
|
||||
border-width: var(--chevron-stroke-width) var(--chevron-stroke-width) 0 0;
|
||||
display: inline-block;
|
||||
width: var(--chevron-size);
|
||||
height: var(--chevron-size);
|
||||
top: 0;
|
||||
left: calc(var(--chevron-size) / 3);
|
||||
transform: rotate(-135deg);
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.page .header p {
|
||||
width: calc(100% - (var(--button-size) * 2));
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page .body {
|
||||
position: relative;
|
||||
top: var(--height);
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
}
|
1
client/extension/assets/img/icon_set_gravatar.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M128 0c-14.144 0-25.6 11.456-25.6 25.6v89.6c0 14.128 11.456 25.6 25.6 25.6 14.144 0 25.6-11.472 25.6-25.6V55.584c29.824 10.56 51.2 38.976 51.2 72.416 0 42.4-34.4 76.8-76.8 76.8S51.2 170.4 51.2 128c0-21.216 8.592-40.416 22.496-54.304 10-10 10-26.208 0-36.208s-26.208-10-36.208 0C14.336 60.64 0 92.64 0 128c0 70.688 57.312 128 128 128s128-57.312 128-128S198.688 0 128 0" fill="#FFFFF"/></svg>
|
After Width: | Height: | Size: 519 B |
1
client/extension/assets/img/icon_set_url.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>
|
After Width: | Height: | Size: 290 B |
BIN
client/extension/assets/img/logo.png
Normal file
After Width: | Height: | Size: 6.9 KiB |
BIN
client/extension/assets/img/logo_icon_128.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
client/extension/assets/img/logo_icon_16.png
Normal file
After Width: | Height: | Size: 480 B |
BIN
client/extension/assets/img/logo_icon_32.png
Normal file
After Width: | Height: | Size: 965 B |
BIN
client/extension/assets/img/logo_icon_48.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
33
client/extension/assets/js/popup.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { AvatarURL } from "./popup_modules/ChangeAvatar.mjs";
|
||||
|
||||
// Localize by replacing __MSG_***__ strings in DOM
|
||||
function localizePage() {
|
||||
for (var i = 0; i < document.body.children.length; i++) {
|
||||
const element = document.body.children[i];
|
||||
|
||||
let valStrH = element.innerHTML.toString();
|
||||
const valNewH = valStrH.replace(/__MSG_(\w+)__/g, (match,key) => {
|
||||
return key ? chrome.i18n.getMessage(key) : "";
|
||||
});
|
||||
|
||||
if(valNewH != valStrH) {
|
||||
element.innerHTML = valNewH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function eventHandler(event) {
|
||||
const target = event.target.closest("[button]");
|
||||
switch(target.getAttribute("button")) {
|
||||
case "avatar:url": new AvatarURL(); break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Bind click listeners to all button attributes
|
||||
for(const button of document.querySelectorAll("[button]")) {
|
||||
button.addEventListener("click",event => eventHandler(event));
|
||||
}
|
||||
});
|
||||
|
||||
localizePage();
|
12
client/extension/assets/js/popup_modules/ChangeAvatar.mjs
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Page } from "./Page.mjs";
|
||||
|
||||
export class AvatarURL extends Page {
|
||||
|
||||
constructor() {
|
||||
super(chrome.i18n.getMessage("avatar_set") + " URL");
|
||||
|
||||
this.appendHTML(`<p>${chrome.i18n.getMessage("avatar_set_url_support")}</p>`);
|
||||
this.open();
|
||||
}
|
||||
|
||||
}
|
70
client/extension/assets/js/popup_modules/Page.mjs
Normal file
|
@ -0,0 +1,70 @@
|
|||
export class Page {
|
||||
|
||||
constructor(title = "") {
|
||||
this.body = null;
|
||||
|
||||
this.pageDepth = () => {
|
||||
return parseInt(getComputedStyle(document.body).getPropertyValue("--page-depth"));
|
||||
}
|
||||
|
||||
this.create(title);
|
||||
}
|
||||
|
||||
create(title) {
|
||||
// Create elements
|
||||
const wrapper = document.createElement("div");
|
||||
this.body = document.createElement("div");
|
||||
const header = document.createElement("div");
|
||||
const backButton = document.createElement("div");
|
||||
|
||||
const pageDepth = (this.pageDepth() + 1) * 100;
|
||||
|
||||
// Add element attributes
|
||||
wrapper.classList.add("page");
|
||||
wrapper.style.setProperty("left",pageDepth + "%");
|
||||
this.body.classList.add("body");
|
||||
header.classList.add("header");
|
||||
backButton.classList.add("back");
|
||||
backButton.setAttribute("title",chrome.i18n.getMessage("page_return"));
|
||||
|
||||
// Attach interfaces
|
||||
wrapper.close = () => this.close(); // Attach public interface to close this page
|
||||
backButton.addEventListener("click",() => this.close());
|
||||
|
||||
// Append document subtree
|
||||
header.appendChild(backButton);
|
||||
header.insertAdjacentHTML("beforeend",`<p>${title}</p>`);
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(this.body);
|
||||
document.body.appendChild(wrapper);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
const wrapper = this.body.closest(".page");
|
||||
|
||||
while(wrapper.firstChild) {
|
||||
wrapper.removeChild(wrapper.lastChild);
|
||||
}
|
||||
wrapper.remove();
|
||||
}
|
||||
|
||||
appendHTML(HTML) {
|
||||
this.body.insertAdjacentHTML("beforeend",HTML);
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
close() {
|
||||
const delay = parseInt(getComputedStyle(document.body).getPropertyValue("--animation-speed"));
|
||||
document.body.style.setProperty("--page-depth",this.pageDepth() - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
this.destroy();
|
||||
},delay);
|
||||
}
|
||||
|
||||
open() {
|
||||
document.body.style.setProperty("--page-depth",this.pageDepth() + 1);
|
||||
}
|
||||
|
||||
}
|
26
client/extension/manifest.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "Stadia Avatar",
|
||||
"version": "1.0.1",
|
||||
"author": "Victor Westerlund",
|
||||
"default_locale": "en",
|
||||
"homepage_url": "https://github.com/VictorWesterlund/stadia-avatar",
|
||||
"description": "__MSG_extension_description__",
|
||||
"icons": {
|
||||
"16": "assets/img/logo_icon_16.png",
|
||||
"32": "assets/img/logo_icon_32.png",
|
||||
"48": "assets/img/logo_icon_48.png",
|
||||
"128": "assets/img/logo_icon_128.png"
|
||||
},
|
||||
"host_permissions": [
|
||||
"*://stadia.google.com/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "__MSG_extension_description__",
|
||||
"default_icon": {
|
||||
"16": "assets/img/logo_icon_16.png",
|
||||
"32": "assets/img/logo_icon_32.png"
|
||||
}
|
||||
},
|
||||
"manifest_version": 3
|
||||
}
|
22
client/extension/popup.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="assets/css/popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img id="myAvatar" src="https://gravatar.com/avatar/default?s=80"/>
|
||||
</header>
|
||||
<ol>
|
||||
<li button="avatar:url" title="__MSG_avatar_set__ URL">
|
||||
<img src="assets/img/icon_set_url.svg"/>
|
||||
<p>__MSG_avatar_set__ URL</p>
|
||||
</li>
|
||||
<li button="avatar:gravatar" title="__MSG_avatar_set__ Gravatar">
|
||||
<img src="assets/img/icon_set_gravatar.svg"/>
|
||||
<p>__MSG_avatar_set__ Gravatar</p>
|
||||
</li>
|
||||
</ol>
|
||||
<script src="assets/js/popup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
132
client/userscript-client.js
Normal file
|
@ -0,0 +1,132 @@
|
|||
// ==UserScript==
|
||||
// @name Stadia Avatars
|
||||
// @namespace https://victorwesterlund.com/
|
||||
// @version 1.0
|
||||
// @description victorWesterlund/stadia-avatar
|
||||
// @author VictorWesterlund
|
||||
// @match https://stadia.google.com/*
|
||||
// @grant none
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const stadiaAvatar = new URL("https://api.victorwesterlund.com/stadia-avatar/get");
|
||||
const gravatar = new URL("https://www.gravatar.com/");
|
||||
|
||||
/*
|
||||
G: Suitable for display on all websites with any audience type.
|
||||
PG: May contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.
|
||||
R: May contain such things as harsh profanity, intense violence, nudity, or hard drug use.
|
||||
X: May contain hardcore sexual imagery or extremely disturbing violence.
|
||||
*/
|
||||
gravatar.searchParams.set("rating","G");
|
||||
|
||||
// Stylesheet for Stadia Avatars
|
||||
class StadiaAvatarCSS {
|
||||
|
||||
constructor() {
|
||||
this.sheet = null;
|
||||
this.createStylesheet();
|
||||
}
|
||||
|
||||
createStylesheet() {
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-stadia-avatars","");
|
||||
style.setAttribute("data-late-css","");
|
||||
|
||||
document.head.appendChild(style);
|
||||
this.sheet = style.sheet;
|
||||
}
|
||||
|
||||
// Serialized group of selectors based on context
|
||||
selectors(group,id = false) {
|
||||
switch(group) {
|
||||
case "me": return `
|
||||
.ksZYgc,
|
||||
.rybUIf
|
||||
`;
|
||||
case "friends": return `
|
||||
c-wiz[data-p='%.@.null,"${id}"]'] .drvCDc,
|
||||
.Y1rZWd[data-player-id="${id}"] .Fnd1Pd,
|
||||
.Y1rZWd[data-playerid="${id}"] .Fnd1Pd,
|
||||
.w2Sl7c[data-playerid="${id}"] .drvCDc
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
add(selectors,avatar) {
|
||||
this.sheet.insertRule(`${selectors} { background-image: url(${avatar}) !important; }`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const avatars = new StadiaAvatarCSS();
|
||||
|
||||
// ----
|
||||
|
||||
// Return the player ID attribute of an element
|
||||
const getID = (target) => {
|
||||
const id = target.getAttribute("data-player-id") ?? target.getAttribute("data-playerid");
|
||||
return id;
|
||||
}
|
||||
|
||||
async function getStadiaAvatar(playerID) {
|
||||
stadiaAvatar.searchParams.set("userID",playerID);
|
||||
|
||||
const response = await fetch(stadiaAvatar);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch avatar and append to stylesheet
|
||||
function replaceWithGravatar(group,playerID) {
|
||||
getStadiaAvatar(playerID).then(response => {
|
||||
if(response.status !== "OK") {
|
||||
return false;
|
||||
}
|
||||
|
||||
gravatar.pathname = "/avatar/" + response.avatar; // Append Gravatar hash
|
||||
avatars.add(avatars.selectors(group,playerID),gravatar); // Add style override by group
|
||||
}).catch(
|
||||
// Ignore missing avatars
|
||||
);
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
replaceWithGravatar("me",getID(document.querySelector("[jsname='HiaYvf']")));
|
||||
|
||||
function updateFamily(group,wrapper) {
|
||||
for(const element of wrapper) {
|
||||
const id = getID(element);
|
||||
if(!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
replaceWithGravatar(group,id);
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = null;
|
||||
|
||||
const friendsList = (mutation,observer) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
let elements = [];
|
||||
elements = Array.prototype.concat.apply(elements,document.querySelector("[jsaction='JIbuQc:mbLu7b']").children);
|
||||
elements = Array.prototype.concat.apply(elements,document.querySelector("[jsname='FhFdCc']").children);
|
||||
|
||||
updateFamily("friends",elements);
|
||||
},700);
|
||||
}
|
||||
|
||||
const friendsMenu = document.querySelector("[jsname='TpfyL']");
|
||||
const friends = new MutationObserver(friendsList);
|
||||
friends.observe(friendsMenu,{
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
})();
|